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.
Files changed (19) hide show
  1. {typecast_python-0.3.9 → typecast_python-0.3.10}/PKG-INFO +1 -1
  2. {typecast_python-0.3.9 → typecast_python-0.3.10}/pyproject.toml +1 -1
  3. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/client.py +19 -2
  4. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/composer.py +46 -104
  5. {typecast_python-0.3.9 → typecast_python-0.3.10}/.gitignore +0 -0
  6. {typecast_python-0.3.9 → typecast_python-0.3.10}/LICENSE +0 -0
  7. {typecast_python-0.3.9 → typecast_python-0.3.10}/README.md +0 -0
  8. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/__init__.py +0 -0
  9. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/_user_agent.py +0 -0
  10. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/_voice_clone.py +0 -0
  11. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/async_client.py +0 -0
  12. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/conf.py +0 -0
  13. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/exceptions.py +0 -0
  14. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/__init__.py +0 -0
  15. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/error.py +0 -0
  16. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/subscription.py +0 -0
  17. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/tts.py +0 -0
  18. {typecast_python-0.3.9 → typecast_python-0.3.10}/src/typecast/models/voices.py +0 -0
  19. {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.9
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.9"
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=response.headers.get("Content-Type", "audio/wav").split("/")[-1],
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.text_to_speech)
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, text_to_speech: Callable[[TTSRequest], TTSResponse]):
59
- self._text_to_speech = text_to_speech
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
- output_format = (
128
- self._defaults.output.audio_format if self._defaults.output else "wav"
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(f"unsupported composed speech output format: {output_format}")
122
+ raise ValueError(
123
+ f"unsupported composed speech output format: {output_format}"
124
+ )
132
125
 
133
- wav_spec: Optional[_WavSpec] = None
134
- output_samples: list[int] = []
126
+ segments: list[dict] = []
135
127
  for part in plan:
136
128
  if isinstance(part, _PausePart):
137
- if wav_spec is None:
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
- request = _settings_to_request(part.text, part.settings)
145
- response = self._text_to_speech(request)
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("voice_id is required for composed speech segments")
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
- if match.start() > last_index:
188
- parts.append(_TextPart(kind="text", text=text[last_index : match.start()]))
189
- parts.append(_PausePart(kind="pause", seconds=float(match.group(1))))
190
- last_index = match.end()
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(base: Optional[Output], override: Optional[Output]) -> Optional[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 = base.model_dump(exclude_none=True, exclude_unset=True) if base is not None else {}
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(text: str, settings: _ComposerSettings) -> TTSRequest:
220
- output = _merge_output(settings.output, Output(audio_format="wav"))
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 if isinstance(settings.model, TTSModel) else TTSModel(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)