cheap-tts 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.
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cheap TTS
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.
22
+
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: cheap-tts
3
+ Version: 0.1.0
4
+ Summary: Cheap TTS Python SDK with local ONNX voice inference
5
+ Author: Cheap TTS
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://www.cheap-tts.com/api-docs/
8
+ Project-URL: Documentation, https://www.cheap-tts.com/api-docs/
9
+ Project-URL: Repository, https://github.com/supperking03/cheap-tts
10
+ Project-URL: Issues, https://github.com/supperking03/cheap-tts/issues
11
+ Keywords: text-to-speech,tts,onnx,voice,vietnamese
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
19
+ Requires-Python: <3.14,>=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: onnxruntime>=1.17
24
+ Dynamic: license-file
25
+
26
+ # Cheap TTS Python SDK
27
+
28
+ Generate multilingual speech with server-side text preparation and local ONNX inference. An active Cheap TTS subscription and a server secret key are required.
29
+
30
+ ```bash
31
+ pip install cheap-tts
32
+ ```
33
+
34
+ ```python
35
+ from cheap_tts import CheapTTS
36
+
37
+ with CheapTTS(secret_key="sk_live_your_project") as tts:
38
+ result = tts.synthesize(
39
+ text="Xin chào từ Cheap TTS.",
40
+ voice="vi-entertainment",
41
+ )
42
+ result.save("audio.wav")
43
+ ```
44
+
45
+ Async usage:
46
+
47
+ ```python
48
+ from cheap_tts import AsyncCheapTTS
49
+
50
+ async with AsyncCheapTTS(secret_key="sk_live_your_project") as tts:
51
+ result = await tts.synthesize("Hello from Cheap TTS.", "en-narration")
52
+ result.save("audio.wav")
53
+ ```
54
+
55
+ ## API
56
+
57
+ - `voices()` returns the available voices.
58
+ - `load_voice()` downloads, validates, caches, and loads one voice.
59
+ - `synthesize()` prepares text through API v1 and performs ONNX inference locally.
60
+ - `clear_cache()` removes locally cached voice assets.
61
+ - `close()` releases the active model.
62
+
63
+ `load_voice()` and `synthesize()` accept `on_progress` and a cancellation object with an `is_set()` method, such as `threading.Event`.
64
+
65
+ ```python
66
+ from threading import Event
67
+
68
+ cancel = Event()
69
+ result = tts.synthesize(
70
+ "A long document",
71
+ "en-narration",
72
+ rate=1.25,
73
+ on_progress=lambda event: print(event.stage, event.progress),
74
+ cancel_event=cancel,
75
+ )
76
+ ```
77
+
78
+ Voice models are cached in `~/.cache/cheap-tts/models`. Set `cache_dir` or `CHEAP_TTS_CACHE_DIR` to change the location.
79
+
80
+ The SDK sends operational health telemetry containing success/failure stage, latency, character count, voice ID, and audio duration. It never sends TTS text or credentials through telemetry. Pass `telemetry=False` to disable it.
81
+
82
+ API errors are raised as `CheapTTSAPIError` with stable `status`, `code`, and `request_id` fields.
83
+
@@ -0,0 +1,58 @@
1
+ # Cheap TTS Python SDK
2
+
3
+ Generate multilingual speech with server-side text preparation and local ONNX inference. An active Cheap TTS subscription and a server secret key are required.
4
+
5
+ ```bash
6
+ pip install cheap-tts
7
+ ```
8
+
9
+ ```python
10
+ from cheap_tts import CheapTTS
11
+
12
+ with CheapTTS(secret_key="sk_live_your_project") as tts:
13
+ result = tts.synthesize(
14
+ text="Xin chào từ Cheap TTS.",
15
+ voice="vi-entertainment",
16
+ )
17
+ result.save("audio.wav")
18
+ ```
19
+
20
+ Async usage:
21
+
22
+ ```python
23
+ from cheap_tts import AsyncCheapTTS
24
+
25
+ async with AsyncCheapTTS(secret_key="sk_live_your_project") as tts:
26
+ result = await tts.synthesize("Hello from Cheap TTS.", "en-narration")
27
+ result.save("audio.wav")
28
+ ```
29
+
30
+ ## API
31
+
32
+ - `voices()` returns the available voices.
33
+ - `load_voice()` downloads, validates, caches, and loads one voice.
34
+ - `synthesize()` prepares text through API v1 and performs ONNX inference locally.
35
+ - `clear_cache()` removes locally cached voice assets.
36
+ - `close()` releases the active model.
37
+
38
+ `load_voice()` and `synthesize()` accept `on_progress` and a cancellation object with an `is_set()` method, such as `threading.Event`.
39
+
40
+ ```python
41
+ from threading import Event
42
+
43
+ cancel = Event()
44
+ result = tts.synthesize(
45
+ "A long document",
46
+ "en-narration",
47
+ rate=1.25,
48
+ on_progress=lambda event: print(event.stage, event.progress),
49
+ cancel_event=cancel,
50
+ )
51
+ ```
52
+
53
+ Voice models are cached in `~/.cache/cheap-tts/models`. Set `cache_dir` or `CHEAP_TTS_CACHE_DIR` to change the location.
54
+
55
+ The SDK sends operational health telemetry containing success/failure stage, latency, character count, voice ID, and audio duration. It never sends TTS text or credentials through telemetry. Pass `telemetry=False` to disable it.
56
+
57
+ API errors are raised as `CheapTTSAPIError` with stable `status`, `code`, and `request_id` fields.
58
+
@@ -0,0 +1,26 @@
1
+ from .async_client import AsyncCheapTTS
2
+ from .client import CheapTTS
3
+ from .errors import (
4
+ CheapTTSAPIError,
5
+ CheapTTSCancelledError,
6
+ CheapTTSDownloadError,
7
+ CheapTTSError,
8
+ CheapTTSModelError,
9
+ )
10
+ from .models import Progress, SynthesisResult, Voice
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "AsyncCheapTTS",
16
+ "CheapTTS",
17
+ "CheapTTSAPIError",
18
+ "CheapTTSCancelledError",
19
+ "CheapTTSDownloadError",
20
+ "CheapTTSError",
21
+ "CheapTTSModelError",
22
+ "Progress",
23
+ "SynthesisResult",
24
+ "Voice",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+ import threading
6
+
7
+ from .client import CheapTTS, DEFAULT_API_BASE
8
+ from .models import CancellationEvent, ProgressCallback, SynthesisResult, Voice
9
+
10
+
11
+ class _CombinedCancellation:
12
+ def __init__(
13
+ self, internal: threading.Event, external: CancellationEvent | None
14
+ ) -> None:
15
+ self._internal = internal
16
+ self._external = external
17
+
18
+ def is_set(self) -> bool:
19
+ return self._internal.is_set() or bool(
20
+ self._external and self._external.is_set()
21
+ )
22
+
23
+
24
+ class AsyncCheapTTS:
25
+ """Async facade over the same tested local runtime used by :class:`CheapTTS`."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ secret_key: str,
31
+ api_base: str = DEFAULT_API_BASE,
32
+ cache_dir: str | Path | None = None,
33
+ telemetry: bool = True,
34
+ timeout: float = 30.0,
35
+ ) -> None:
36
+ self._client = CheapTTS(
37
+ secret_key=secret_key,
38
+ api_base=api_base,
39
+ cache_dir=cache_dir,
40
+ telemetry=telemetry,
41
+ timeout=timeout,
42
+ )
43
+
44
+ async def voices(self) -> list[Voice]:
45
+ return await asyncio.to_thread(self._client.voices)
46
+
47
+ async def load_voice(
48
+ self,
49
+ voice: str,
50
+ *,
51
+ on_progress: ProgressCallback | None = None,
52
+ cancel_event: CancellationEvent | None = None,
53
+ ) -> Voice:
54
+ internal = threading.Event()
55
+ combined = _CombinedCancellation(internal, cancel_event)
56
+ try:
57
+ return await asyncio.to_thread(
58
+ self._client.load_voice,
59
+ voice,
60
+ on_progress=on_progress,
61
+ cancel_event=combined,
62
+ )
63
+ except asyncio.CancelledError:
64
+ internal.set()
65
+ raise
66
+
67
+ async def synthesize(
68
+ self,
69
+ text: str,
70
+ voice: str,
71
+ *,
72
+ rate: float = 1.0,
73
+ on_progress: ProgressCallback | None = None,
74
+ cancel_event: CancellationEvent | None = None,
75
+ ) -> SynthesisResult:
76
+ internal = threading.Event()
77
+ combined = _CombinedCancellation(internal, cancel_event)
78
+ try:
79
+ return await asyncio.to_thread(
80
+ self._client.synthesize,
81
+ text,
82
+ voice,
83
+ rate=rate,
84
+ on_progress=on_progress,
85
+ cancel_event=combined,
86
+ )
87
+ except asyncio.CancelledError:
88
+ internal.set()
89
+ raise
90
+
91
+ async def clear_cache(self) -> None:
92
+ await asyncio.to_thread(self._client.clear_cache)
93
+
94
+ async def close(self) -> None:
95
+ await asyncio.to_thread(self._client.close)
96
+
97
+ async def __aenter__(self) -> "AsyncCheapTTS":
98
+ return self
99
+
100
+ async def __aexit__(
101
+ self, _type: object, _value: object, _traceback: object
102
+ ) -> None:
103
+ await self.close()
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from io import BytesIO
4
+ import wave
5
+
6
+ import numpy as np
7
+ from numpy.typing import NDArray
8
+
9
+
10
+ FloatAudio = NDArray[np.float32]
11
+
12
+
13
+ def encode_wav(samples: FloatAudio, sample_rate: int) -> bytes:
14
+ clipped = np.clip(np.asarray(samples, dtype=np.float32), -1.0, 1.0)
15
+ scaled = np.where(clipped < 0, clipped * 32768.0, clipped * 32767.0)
16
+ pcm = scaled.astype("<i2", copy=False)
17
+ output = BytesIO()
18
+ with wave.open(output, "wb") as wav:
19
+ wav.setnchannels(1)
20
+ wav.setsampwidth(2)
21
+ wav.setframerate(sample_rate)
22
+ wav.writeframes(pcm.tobytes())
23
+ return output.getvalue()
24
+
25
+
26
+ def stretch_time(input_audio: FloatAudio, rate: float, sample_rate: int) -> FloatAudio:
27
+ """Pitch-preserving WSOLA time stretch, matching the Node SDK behavior."""
28
+ source = np.asarray(input_audio, dtype=np.float32).reshape(-1)
29
+ if rate <= 0 or abs(rate - 1.0) < 1e-3 or source.size == 0:
30
+ return source
31
+ frame = round(sample_rate * 0.03)
32
+ if source.size <= frame * 3:
33
+ return source
34
+ overlap = frame // 2
35
+ hop_out = frame - overlap
36
+ tolerance = round(sample_rate * 0.008)
37
+ output = np.zeros(max(frame, round(source.size / rate)), dtype=np.float32)
38
+ window = np.hanning(frame).astype(np.float32)
39
+ output[:frame] = source[:frame] * window
40
+ output[:overlap] += source[:overlap] * (1.0 - window[:overlap])
41
+
42
+ previous_input = 0
43
+ iteration = 1
44
+ while True:
45
+ output_position = iteration * hop_out
46
+ if output_position + frame > output.size:
47
+ break
48
+ center = round(output_position * rate)
49
+ if center + frame + tolerance >= source.size:
50
+ break
51
+ target = previous_input + hop_out
52
+ reference = source[target : target + overlap : 2]
53
+ best = center
54
+ best_score = float("-inf")
55
+ for start in range(max(0, center - tolerance), center + tolerance + 1):
56
+ candidate = source[start : start + overlap : 2]
57
+ energy = float(np.dot(candidate, candidate)) + 1e-9
58
+ score = float(np.dot(candidate, reference)) / energy**0.5
59
+ if score > best_score:
60
+ best_score = score
61
+ best = start
62
+ output[output_position : output_position + frame] += (
63
+ source[best : best + frame] * window
64
+ )
65
+ previous_input = best
66
+ iteration += 1
67
+ return output
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import secrets
9
+ import time
10
+ from typing import Iterator
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.request import Request, urlopen
13
+
14
+ from .errors import CheapTTSCancelledError, CheapTTSDownloadError
15
+ from .models import CancellationEvent, Progress, ProgressCallback
16
+
17
+
18
+ LOCK_STALE_SECONDS = 10 * 60
19
+ LOCK_WAIT_SECONDS = 0.25
20
+
21
+
22
+ def default_cache_dir() -> Path:
23
+ configured = os.environ.get("CHEAP_TTS_CACHE_DIR")
24
+ return (
25
+ Path(configured).expanduser()
26
+ if configured
27
+ else Path.home() / ".cache" / "cheap-tts" / "models"
28
+ )
29
+
30
+
31
+ def cache_paths(cache_dir: Path, model_base: str, asset: str) -> tuple[Path, Path]:
32
+ key = hashlib.sha256(f"{model_base.rstrip('/')}/{asset}".encode()).hexdigest()[:20]
33
+ return cache_dir / f"{key}.onnx", cache_dir / f"{key}.json"
34
+
35
+
36
+ def check_cancelled(cancel_event: CancellationEvent | None) -> None:
37
+ if cancel_event is not None and cancel_event.is_set():
38
+ raise CheapTTSCancelledError("operation cancelled")
39
+
40
+
41
+ @contextmanager
42
+ def file_lock(
43
+ path: Path, cancel_event: CancellationEvent | None = None
44
+ ) -> Iterator[None]:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ while True:
47
+ check_cancelled(cancel_event)
48
+ try:
49
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
50
+ os.close(descriptor)
51
+ break
52
+ except FileExistsError:
53
+ try:
54
+ if time.time() - path.stat().st_mtime > LOCK_STALE_SECONDS:
55
+ path.unlink(missing_ok=True)
56
+ continue
57
+ except FileNotFoundError:
58
+ continue
59
+ time.sleep(LOCK_WAIT_SECONDS)
60
+ try:
61
+ yield
62
+ finally:
63
+ path.unlink(missing_ok=True)
64
+
65
+
66
+ def _atomic_download(
67
+ url: str,
68
+ destination: Path,
69
+ *,
70
+ on_progress: ProgressCallback | None = None,
71
+ cancel_event: CancellationEvent | None = None,
72
+ timeout: float = 60.0,
73
+ ) -> None:
74
+ destination.parent.mkdir(parents=True, exist_ok=True)
75
+ temporary = destination.with_name(
76
+ f"{destination.name}.{os.getpid()}.{secrets.token_hex(6)}.tmp"
77
+ )
78
+ loaded = 0
79
+ try:
80
+ request = Request(url, headers={"User-Agent": "cheap-tts-python/0.1.0"})
81
+ with (
82
+ urlopen(request, timeout=timeout) as response,
83
+ temporary.open("xb") as output,
84
+ ):
85
+ raw_total = response.headers.get("content-length")
86
+ total = int(raw_total) if raw_total and raw_total.isdigit() else None
87
+ while True:
88
+ check_cancelled(cancel_event)
89
+ chunk = response.read(256 * 1024)
90
+ if not chunk:
91
+ break
92
+ output.write(chunk)
93
+ loaded += len(chunk)
94
+ if on_progress:
95
+ on_progress(
96
+ Progress(
97
+ "voice", loaded / total if total else None, loaded, total
98
+ )
99
+ )
100
+ output.flush()
101
+ os.fsync(output.fileno())
102
+ if loaded == 0:
103
+ raise CheapTTSDownloadError("voice asset download was empty")
104
+ os.replace(temporary, destination)
105
+ if on_progress:
106
+ on_progress(Progress("voice", 1.0, loaded, total))
107
+ except CheapTTSCancelledError:
108
+ raise
109
+ except (HTTPError, URLError, OSError) as exc:
110
+ raise CheapTTSDownloadError(
111
+ f"voice asset download failed: {type(exc).__name__}"
112
+ ) from exc
113
+ finally:
114
+ temporary.unlink(missing_ok=True)
115
+
116
+
117
+ def ensure_voice_files(
118
+ cache_dir: Path,
119
+ model_base: str,
120
+ asset: str,
121
+ *,
122
+ on_progress: ProgressCallback | None = None,
123
+ cancel_event: CancellationEvent | None = None,
124
+ timeout: float = 60.0,
125
+ ) -> tuple[Path, dict]:
126
+ model_path, config_path = cache_paths(cache_dir, model_base, asset)
127
+ with file_lock(model_path.with_suffix(".lock"), cancel_event):
128
+ if not config_path.is_file():
129
+ _atomic_download(
130
+ f"{model_base.rstrip('/')}/{asset}.onnx.json",
131
+ config_path,
132
+ cancel_event=cancel_event,
133
+ timeout=timeout,
134
+ )
135
+ try:
136
+ config = json.loads(config_path.read_text("utf-8"))
137
+ except (OSError, ValueError):
138
+ config_path.unlink(missing_ok=True)
139
+ _atomic_download(
140
+ f"{model_base.rstrip('/')}/{asset}.onnx.json",
141
+ config_path,
142
+ cancel_event=cancel_event,
143
+ timeout=timeout,
144
+ )
145
+ try:
146
+ config = json.loads(config_path.read_text("utf-8"))
147
+ except (OSError, ValueError) as exc:
148
+ config_path.unlink(missing_ok=True)
149
+ raise CheapTTSDownloadError("voice configuration is invalid") from exc
150
+ if not model_path.is_file() or model_path.stat().st_size < 1024:
151
+ model_path.unlink(missing_ok=True)
152
+ _atomic_download(
153
+ f"{model_base.rstrip('/')}/{asset}.onnx",
154
+ model_path,
155
+ on_progress=on_progress,
156
+ cancel_event=cancel_event,
157
+ timeout=timeout,
158
+ )
159
+ elif on_progress:
160
+ on_progress(
161
+ Progress(
162
+ "voice", 1.0, model_path.stat().st_size, model_path.stat().st_size
163
+ )
164
+ )
165
+ return model_path, config
166
+
167
+
168
+ def refresh_voice_model(
169
+ cache_dir: Path,
170
+ model_base: str,
171
+ asset: str,
172
+ *,
173
+ on_progress: ProgressCallback | None = None,
174
+ cancel_event: CancellationEvent | None = None,
175
+ timeout: float = 60.0,
176
+ ) -> tuple[Path, dict]:
177
+ """Atomically replace a cached model after the runtime rejects it."""
178
+ model_path, config_path = cache_paths(cache_dir, model_base, asset)
179
+ with file_lock(model_path.with_suffix(".lock"), cancel_event):
180
+ model_path.unlink(missing_ok=True)
181
+ _atomic_download(
182
+ f"{model_base.rstrip('/')}/{asset}.onnx",
183
+ model_path,
184
+ on_progress=on_progress,
185
+ cancel_event=cancel_event,
186
+ timeout=timeout,
187
+ )
188
+ try:
189
+ config = json.loads(config_path.read_text("utf-8"))
190
+ except (OSError, ValueError) as exc:
191
+ raise CheapTTSDownloadError("voice configuration is invalid") from exc
192
+ return model_path, config