typecast-python 0.3.9__tar.gz → 0.3.10__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.
- {typecast_python-0.3.9 → typecast_python-0.3.10}/PKG-INFO +1 -1
- {typecast_python-0.3.9 → typecast_python-0.3.10}/pyproject.toml +1 -1
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/client.py +19 -2
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/composer.py +46 -104
- {typecast_python-0.3.9 → typecast_python-0.3.10}/.gitignore +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/LICENSE +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/README.md +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/__init__.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/_user_agent.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/_voice_clone.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/async_client.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/conf.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/exceptions.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/__init__.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/error.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/subscription.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/tts.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/voices.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: typecast-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.10
|
|
4
4
|
Summary: Official Typecast Python SDK - Convert text to lifelike speech using AI-powered voices
|
|
5
5
|
Project-URL: Homepage, https://typecast.ai
|
|
6
6
|
Project-URL: Documentation, https://typecast.ai/docs/overview
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "typecast-python"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.10"
|
|
8
8
|
description = "Official Typecast Python SDK - Convert text to lifelike speech using AI-powered voices"
|
|
9
9
|
authors = [
|
|
10
10
|
{name = "Neosapience", email = "help@typecast.ai"}
|
|
@@ -197,10 +197,11 @@ class Typecast:
|
|
|
197
197
|
if response.status_code != 200:
|
|
198
198
|
self._handle_error(response.status_code, response.text)
|
|
199
199
|
|
|
200
|
+
content_type = response.headers.get("Content-Type", "audio/wav").lower()
|
|
200
201
|
return TTSResponse(
|
|
201
202
|
audio_data=response.content,
|
|
202
203
|
duration=response.headers.get("X-Audio-Duration", 0),
|
|
203
|
-
format=
|
|
204
|
+
format="mp3" if "mp3" in content_type or "mpeg" in content_type else "wav",
|
|
204
205
|
)
|
|
205
206
|
|
|
206
207
|
def compose_speech(self) -> SpeechComposer:
|
|
@@ -209,7 +210,23 @@ class Typecast:
|
|
|
209
210
|
Text passed to ``say()`` may include pause markup such as ``<|0.3s|>``.
|
|
210
211
|
``pause(seconds)`` also uses seconds, e.g. ``0.3`` for 300 ms.
|
|
211
212
|
"""
|
|
212
|
-
return SpeechComposer(self.
|
|
213
|
+
return SpeechComposer(self.compose_text_to_speech)
|
|
214
|
+
|
|
215
|
+
def compose_text_to_speech(self, segments: list[dict]) -> TTSResponse:
|
|
216
|
+
response = self.session.post(
|
|
217
|
+
f"{self.host}/v1/text-to-speech/compose",
|
|
218
|
+
json={"segments": segments},
|
|
219
|
+
headers=self._request_headers(),
|
|
220
|
+
timeout=(10, 300),
|
|
221
|
+
)
|
|
222
|
+
if response.status_code != 200:
|
|
223
|
+
self._handle_error(response.status_code, response.text)
|
|
224
|
+
content_type = response.headers.get("Content-Type", "audio/wav").lower()
|
|
225
|
+
return TTSResponse(
|
|
226
|
+
audio_data=response.content,
|
|
227
|
+
duration=response.headers.get("X-Audio-Duration", 0),
|
|
228
|
+
format="mp3" if "mp3" in content_type or "mpeg" in content_type else "wav",
|
|
229
|
+
)
|
|
213
230
|
|
|
214
231
|
def generate_to_file(
|
|
215
232
|
self,
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import io
|
|
4
3
|
import math
|
|
5
4
|
import re
|
|
6
|
-
import struct
|
|
7
|
-
import wave
|
|
8
5
|
from dataclasses import dataclass
|
|
9
6
|
from typing import Callable, Literal, Optional, Union
|
|
10
7
|
|
|
@@ -31,19 +28,6 @@ class _SpeechPart:
|
|
|
31
28
|
settings: "_ComposerSettings"
|
|
32
29
|
|
|
33
30
|
|
|
34
|
-
@dataclass(frozen=True)
|
|
35
|
-
class _WavSpec:
|
|
36
|
-
sample_rate: int
|
|
37
|
-
channels: int
|
|
38
|
-
sample_width: int
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@dataclass(frozen=True)
|
|
42
|
-
class _ParsedWav:
|
|
43
|
-
spec: _WavSpec
|
|
44
|
-
samples: list[int]
|
|
45
|
-
|
|
46
|
-
|
|
47
31
|
@dataclass(frozen=True)
|
|
48
32
|
class _ComposerSettings:
|
|
49
33
|
voice_id: Optional[str] = None
|
|
@@ -55,8 +39,8 @@ class _ComposerSettings:
|
|
|
55
39
|
|
|
56
40
|
|
|
57
41
|
class SpeechComposer:
|
|
58
|
-
def __init__(self,
|
|
59
|
-
self.
|
|
42
|
+
def __init__(self, compose: Callable[[list[dict]], TTSResponse]):
|
|
43
|
+
self._compose = compose
|
|
60
44
|
self._defaults = _ComposerSettings()
|
|
61
45
|
self._parts: list[Union[_SpeechPart, _PausePart]] = []
|
|
62
46
|
|
|
@@ -124,41 +108,29 @@ class SpeechComposer:
|
|
|
124
108
|
if not any(isinstance(part, _SpeechPart) for part in plan):
|
|
125
109
|
raise ValueError("at least one speech segment is required")
|
|
126
110
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
111
|
+
formats = {
|
|
112
|
+
part.settings.output.audio_format
|
|
113
|
+
for part in plan
|
|
114
|
+
if isinstance(part, _SpeechPart)
|
|
115
|
+
and part.settings.output
|
|
116
|
+
and part.settings.output.audio_format
|
|
117
|
+
}
|
|
118
|
+
if len(formats) > 1:
|
|
119
|
+
raise ValueError("composed speech segments must use one audio format")
|
|
120
|
+
output_format = next(iter(formats), "wav")
|
|
130
121
|
if output_format not in ("wav", "mp3"):
|
|
131
|
-
raise ValueError(
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"unsupported composed speech output format: {output_format}"
|
|
124
|
+
)
|
|
132
125
|
|
|
133
|
-
|
|
134
|
-
output_samples: list[int] = []
|
|
126
|
+
segments: list[dict] = []
|
|
135
127
|
for part in plan:
|
|
136
128
|
if isinstance(part, _PausePart):
|
|
137
|
-
|
|
138
|
-
raise ValueError("pause cannot be the first composed part")
|
|
139
|
-
output_samples.extend(
|
|
140
|
-
[0] * _seconds_to_samples(part.seconds, wav_spec.sample_rate)
|
|
141
|
-
)
|
|
129
|
+
segments.append({"type": "pause", "duration_seconds": part.seconds})
|
|
142
130
|
continue
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
wav = _parse_wav(response.audio_data)
|
|
147
|
-
if wav_spec is not None and wav.spec != wav_spec:
|
|
148
|
-
raise ValueError("all composed WAV segments must use the same PCM format")
|
|
149
|
-
wav_spec = wav.spec
|
|
150
|
-
output_samples.extend(_trim_silence(wav.samples))
|
|
151
|
-
|
|
152
|
-
final_spec = wav_spec
|
|
153
|
-
assert final_spec is not None
|
|
154
|
-
wav_bytes = _encode_wav(output_samples, final_spec)
|
|
155
|
-
if output_format == "mp3":
|
|
156
|
-
raise ValueError("ffmpeg is required to encode composed speech as mp3")
|
|
157
|
-
return TTSResponse(
|
|
158
|
-
audio_data=wav_bytes,
|
|
159
|
-
duration=len(output_samples) / final_spec.sample_rate,
|
|
160
|
-
format="wav",
|
|
161
|
-
)
|
|
131
|
+
request = _settings_to_request(part.text, part.settings, output_format)
|
|
132
|
+
segments.append({"type": "tts", **request.model_dump(exclude_none=True)})
|
|
133
|
+
return self._compose(segments)
|
|
162
134
|
|
|
163
135
|
def _build_plan(self) -> list[Union[_SpeechPart, _PausePart]]:
|
|
164
136
|
plan: list[Union[_SpeechPart, _PausePart]] = []
|
|
@@ -173,7 +145,9 @@ class SpeechComposer:
|
|
|
173
145
|
if not parsed.text.strip():
|
|
174
146
|
continue
|
|
175
147
|
if not part.settings.voice_id:
|
|
176
|
-
raise ValueError(
|
|
148
|
+
raise ValueError(
|
|
149
|
+
"voice_id is required for composed speech segments"
|
|
150
|
+
)
|
|
177
151
|
if not part.settings.model:
|
|
178
152
|
raise ValueError("model is required for composed speech segments")
|
|
179
153
|
plan.append(_SpeechPart(text=parsed.text, settings=part.settings))
|
|
@@ -184,10 +158,14 @@ def parse_pause_markup(text: str) -> list[Union[_TextPart, _PausePart]]:
|
|
|
184
158
|
parts: list[Union[_TextPart, _PausePart]] = []
|
|
185
159
|
last_index = 0
|
|
186
160
|
for match in _PAUSE_TOKEN.finditer(text):
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
161
|
+
seconds = float(match.group(1))
|
|
162
|
+
if math.isfinite(seconds) and seconds > 0:
|
|
163
|
+
if match.start() > last_index:
|
|
164
|
+
parts.append(
|
|
165
|
+
_TextPart(kind="text", text=text[last_index : match.start()])
|
|
166
|
+
)
|
|
167
|
+
parts.append(_PausePart(kind="pause", seconds=seconds))
|
|
168
|
+
last_index = match.end()
|
|
191
169
|
if last_index < len(text):
|
|
192
170
|
parts.append(_TextPart(kind="text", text=text[last_index:]))
|
|
193
171
|
return parts
|
|
@@ -207,69 +185,33 @@ def _merge_settings(
|
|
|
207
185
|
)
|
|
208
186
|
|
|
209
187
|
|
|
210
|
-
def _merge_output(
|
|
188
|
+
def _merge_output(
|
|
189
|
+
base: Optional[Output], override: Optional[Output]
|
|
190
|
+
) -> Optional[Output]:
|
|
211
191
|
if base is None and override is None:
|
|
212
192
|
return None
|
|
213
|
-
data =
|
|
193
|
+
data = (
|
|
194
|
+
base.model_dump(exclude_none=True, exclude_unset=True)
|
|
195
|
+
if base is not None
|
|
196
|
+
else {}
|
|
197
|
+
)
|
|
214
198
|
if override is not None:
|
|
215
199
|
data.update(override.model_dump(exclude_none=True, exclude_unset=True))
|
|
216
200
|
return Output(**data)
|
|
217
201
|
|
|
218
202
|
|
|
219
|
-
def _settings_to_request(
|
|
220
|
-
|
|
203
|
+
def _settings_to_request(
|
|
204
|
+
text: str, settings: _ComposerSettings, output_format: str
|
|
205
|
+
) -> TTSRequest:
|
|
206
|
+
output = _merge_output(settings.output, Output(audio_format=output_format))
|
|
221
207
|
return TTSRequest(
|
|
222
208
|
text=text,
|
|
223
209
|
voice_id=settings.voice_id or "",
|
|
224
|
-
model=settings.model
|
|
210
|
+
model=settings.model
|
|
211
|
+
if isinstance(settings.model, TTSModel)
|
|
212
|
+
else TTSModel(settings.model),
|
|
225
213
|
language=settings.language,
|
|
226
214
|
prompt=settings.prompt,
|
|
227
215
|
output=output,
|
|
228
216
|
seed=settings.seed,
|
|
229
217
|
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def _parse_wav(data: bytes) -> _ParsedWav:
|
|
233
|
-
try:
|
|
234
|
-
with wave.open(io.BytesIO(data), "rb") as reader:
|
|
235
|
-
channels = reader.getnchannels()
|
|
236
|
-
sample_width = reader.getsampwidth()
|
|
237
|
-
sample_rate = reader.getframerate()
|
|
238
|
-
if channels != 1 or sample_width != 2:
|
|
239
|
-
raise ValueError("only mono 16-bit PCM WAV is supported for composed speech")
|
|
240
|
-
frames = reader.readframes(reader.getnframes())
|
|
241
|
-
except (EOFError, wave.Error) as exc:
|
|
242
|
-
raise ValueError("unsupported WAV data") from exc
|
|
243
|
-
samples = [
|
|
244
|
-
struct.unpack("<h", frames[offset : offset + 2])[0]
|
|
245
|
-
for offset in range(0, len(frames), 2)
|
|
246
|
-
]
|
|
247
|
-
return _ParsedWav(
|
|
248
|
-
spec=_WavSpec(sample_rate=sample_rate, channels=channels, sample_width=sample_width),
|
|
249
|
-
samples=samples,
|
|
250
|
-
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def _encode_wav(samples: list[int], spec: _WavSpec) -> bytes:
|
|
254
|
-
payload = b"".join(struct.pack("<h", sample) for sample in samples)
|
|
255
|
-
out = io.BytesIO()
|
|
256
|
-
with wave.open(out, "wb") as writer:
|
|
257
|
-
writer.setnchannels(spec.channels)
|
|
258
|
-
writer.setsampwidth(spec.sample_width)
|
|
259
|
-
writer.setframerate(spec.sample_rate)
|
|
260
|
-
writer.writeframes(payload)
|
|
261
|
-
return out.getvalue()
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
def _trim_silence(samples: list[int]) -> list[int]:
|
|
265
|
-
start = 0
|
|
266
|
-
end = len(samples)
|
|
267
|
-
while start < end and samples[start] == 0:
|
|
268
|
-
start += 1
|
|
269
|
-
while end > start and samples[end - 1] == 0:
|
|
270
|
-
end -= 1
|
|
271
|
-
return samples[start:end]
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _seconds_to_samples(seconds: float, sample_rate: int) -> int:
|
|
275
|
-
return round(seconds * sample_rate)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|