typecast-python 0.3.4__py3-none-any.whl → 0.3.5__py3-none-any.whl

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/client.py CHANGED
@@ -10,6 +10,7 @@ from ._voice_clone import (
10
10
  validate_clone_inputs,
11
11
  validate_custom_voice_id,
12
12
  )
13
+ from .composer import SpeechComposer
13
14
  from .exceptions import (
14
15
  BadRequestError,
15
16
  InternalServerError,
@@ -166,6 +167,14 @@ class Typecast:
166
167
  format=response.headers.get("Content-Type", "audio/wav").split("/")[-1],
167
168
  )
168
169
 
170
+ def compose_speech(self) -> SpeechComposer:
171
+ """Build composed speech from multiple text and pause segments.
172
+
173
+ Text passed to ``say()`` may include pause markup such as ``<|0.3s|>``.
174
+ ``pause(seconds)`` also uses seconds, e.g. ``0.3`` for 300 ms.
175
+ """
176
+ return SpeechComposer(self.text_to_speech)
177
+
169
178
  def generate_to_file(
170
179
  self,
171
180
  path: Union[str, Path],
typecast/composer.py ADDED
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import math
5
+ import re
6
+ import struct
7
+ import wave
8
+ from dataclasses import dataclass
9
+ from typing import Callable, Literal, Optional, Union
10
+
11
+ from .models import LanguageCode, Output, TTSModel, TTSPrompt, TTSRequest, TTSResponse
12
+
13
+ _PAUSE_TOKEN = re.compile(r"<\|(\d+(?:\.\d+)?)s\|>")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class _TextPart:
18
+ kind: Literal["text"]
19
+ text: str
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _PausePart:
24
+ kind: Literal["pause"]
25
+ seconds: float
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class _SpeechPart:
30
+ text: str
31
+ settings: "_ComposerSettings"
32
+
33
+
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
+ @dataclass(frozen=True)
48
+ class _ComposerSettings:
49
+ voice_id: Optional[str] = None
50
+ model: Optional[Union[TTSModel, str]] = None
51
+ language: Optional[Union[LanguageCode, str]] = None
52
+ prompt: Optional[TTSPrompt] = None
53
+ output: Optional[Output] = None
54
+ seed: Optional[int] = None
55
+
56
+
57
+ class SpeechComposer:
58
+ def __init__(self, text_to_speech: Callable[[TTSRequest], TTSResponse]):
59
+ self._text_to_speech = text_to_speech
60
+ self._defaults = _ComposerSettings()
61
+ self._parts: list[Union[_SpeechPart, _PausePart]] = []
62
+
63
+ def defaults(
64
+ self,
65
+ *,
66
+ voice_id: Optional[str] = None,
67
+ model: Optional[Union[TTSModel, str]] = None,
68
+ language: Optional[Union[LanguageCode, str]] = None,
69
+ prompt: Optional[TTSPrompt] = None,
70
+ output: Optional[Output] = None,
71
+ seed: Optional[int] = None,
72
+ ) -> "SpeechComposer":
73
+ self._defaults = _merge_settings(
74
+ self._defaults,
75
+ _ComposerSettings(
76
+ voice_id=voice_id,
77
+ model=model,
78
+ language=language,
79
+ prompt=prompt,
80
+ output=output,
81
+ seed=seed,
82
+ ),
83
+ )
84
+ return self
85
+
86
+ def say(
87
+ self,
88
+ text: str,
89
+ *,
90
+ voice_id: Optional[str] = None,
91
+ model: Optional[Union[TTSModel, str]] = None,
92
+ language: Optional[Union[LanguageCode, str]] = None,
93
+ prompt: Optional[TTSPrompt] = None,
94
+ output: Optional[Output] = None,
95
+ seed: Optional[int] = None,
96
+ ) -> "SpeechComposer":
97
+ settings = _merge_settings(
98
+ self._defaults,
99
+ _ComposerSettings(
100
+ voice_id=voice_id,
101
+ model=model,
102
+ language=language,
103
+ prompt=prompt,
104
+ output=output,
105
+ seed=seed,
106
+ ),
107
+ )
108
+ self._parts.append(_SpeechPart(text=text, settings=settings))
109
+ return self
110
+
111
+ def pause(self, seconds: float) -> "SpeechComposer":
112
+ """Insert silence between speech segments.
113
+
114
+ Args:
115
+ seconds: Duration in seconds. Use 0.3 for 300 ms, 3 for 3 seconds.
116
+ """
117
+ if not math.isfinite(seconds) or seconds <= 0:
118
+ raise ValueError("pause seconds must be greater than 0")
119
+ self._parts.append(_PausePart(kind="pause", seconds=seconds))
120
+ return self
121
+
122
+ def generate(self) -> TTSResponse:
123
+ plan = self._build_plan()
124
+ if not any(isinstance(part, _SpeechPart) for part in plan):
125
+ raise ValueError("at least one speech segment is required")
126
+
127
+ output_format = (
128
+ self._defaults.output.audio_format if self._defaults.output else "wav"
129
+ )
130
+ if output_format not in ("wav", "mp3"):
131
+ raise ValueError(f"unsupported composed speech output format: {output_format}")
132
+
133
+ wav_spec: Optional[_WavSpec] = None
134
+ output_samples: list[int] = []
135
+ for part in plan:
136
+ 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
+ )
142
+ 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
+ )
162
+
163
+ def _build_plan(self) -> list[Union[_SpeechPart, _PausePart]]:
164
+ plan: list[Union[_SpeechPart, _PausePart]] = []
165
+ for part in self._parts:
166
+ if isinstance(part, _PausePart):
167
+ plan.append(part)
168
+ continue
169
+ for parsed in parse_pause_markup(part.text):
170
+ if isinstance(parsed, _PausePart):
171
+ plan.append(parsed)
172
+ continue
173
+ if not parsed.text.strip():
174
+ continue
175
+ if not part.settings.voice_id:
176
+ raise ValueError("voice_id is required for composed speech segments")
177
+ if not part.settings.model:
178
+ raise ValueError("model is required for composed speech segments")
179
+ plan.append(_SpeechPart(text=parsed.text, settings=part.settings))
180
+ return plan
181
+
182
+
183
+ def parse_pause_markup(text: str) -> list[Union[_TextPart, _PausePart]]:
184
+ parts: list[Union[_TextPart, _PausePart]] = []
185
+ last_index = 0
186
+ 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()
191
+ if last_index < len(text):
192
+ parts.append(_TextPart(kind="text", text=text[last_index:]))
193
+ return parts
194
+
195
+
196
+ def _merge_settings(
197
+ base: _ComposerSettings,
198
+ override: _ComposerSettings,
199
+ ) -> _ComposerSettings:
200
+ return _ComposerSettings(
201
+ voice_id=override.voice_id if override.voice_id is not None else base.voice_id,
202
+ model=override.model if override.model is not None else base.model,
203
+ language=override.language if override.language is not None else base.language,
204
+ prompt=override.prompt if override.prompt is not None else base.prompt,
205
+ output=_merge_output(base.output, override.output),
206
+ seed=override.seed if override.seed is not None else base.seed,
207
+ )
208
+
209
+
210
+ def _merge_output(base: Optional[Output], override: Optional[Output]) -> Optional[Output]:
211
+ if base is None and override is None:
212
+ return None
213
+ data = base.model_dump(exclude_none=True, exclude_unset=True) if base is not None else {}
214
+ if override is not None:
215
+ data.update(override.model_dump(exclude_none=True, exclude_unset=True))
216
+ return Output(**data)
217
+
218
+
219
+ def _settings_to_request(text: str, settings: _ComposerSettings) -> TTSRequest:
220
+ output = _merge_output(settings.output, Output(audio_format="wav"))
221
+ return TTSRequest(
222
+ text=text,
223
+ voice_id=settings.voice_id or "",
224
+ model=settings.model if isinstance(settings.model, TTSModel) else TTSModel(settings.model),
225
+ language=settings.language,
226
+ prompt=settings.prompt,
227
+ output=output,
228
+ seed=settings.seed,
229
+ )
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typecast-python
3
- Version: 0.3.4
3
+ Version: 0.3.5
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
@@ -1,7 +1,8 @@
1
1
  typecast/__init__.py,sha256=3pdJqNkXCZ7svzqab4sBR_qwyoM5E2sPjfuci1g1Ub8,1047
2
2
  typecast/_voice_clone.py,sha256=TN2tbB3b5lC5uFCBnbERhz37bNJlbv6VZ-vj70EfTs4,3464
3
3
  typecast/async_client.py,sha256=5Tz6j2waj4XHgHhoaxM__W3YcJxUS7r8Wc3RkZA-LBc,18645
4
- typecast/client.py,sha256=oMjYK6NEYivY5ICSst-ZKH13ORnbzfGGlC_ZLhxk_PM,17461
4
+ typecast/client.py,sha256=U2X_TEesBxBBvm9ta8M9mZS7ofkx0iXIBqh1k_5mX2I,17834
5
+ typecast/composer.py,sha256=r1kpRGlSJ1s4C1PkE7yIpGwnKtxYfIdFSLWaf8v1RAQ,9422
5
6
  typecast/conf.py,sha256=yfOHYZDvIshWWMriN5wE0GFPR8AX3zv4Rg2655OvA9Q,724
6
7
  typecast/exceptions.py,sha256=Y0ZzYebe8zOSOSAHbXfKR0G_RJgdmZXxi15Z7ZxPLIk,1568
7
8
  typecast/utils.py,sha256=XuNuX7gW8_CGKqZ-cv_tKlPVMPBluAYJBw2clwmjIMI,708
@@ -10,7 +11,7 @@ typecast/models/error.py,sha256=XomIjx7jvlCjItqzJuCAT4mXC9jwTjxR8lLDUk6P8KA,152
10
11
  typecast/models/subscription.py,sha256=EIaAAo3cCRw8LYT_O6D9AVwxqIHrWCijzl4UTx7FZB8,894
11
12
  typecast/models/tts.py,sha256=uZ8QEevpAnRF-W4_hoEx4EA1WP1TN0ZJIak1OYE1ThQ,16370
12
13
  typecast/models/voices.py,sha256=-EXP35jDy7_G30k5bDnVrFJHp6svEDTA5jJ8oHAgXNQ,2310
13
- typecast_python-0.3.4.dist-info/METADATA,sha256=TS2HvMhG4jAK_Y7r_dQK2Kiun3gz696PXUsuGDWZgyA,25530
14
- typecast_python-0.3.4.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
- typecast_python-0.3.4.dist-info/licenses/LICENSE,sha256=HvtJ-S89uUkuYmt-OvVk4MRxmzwtbn84__qJtSrGU2Q,11348
16
- typecast_python-0.3.4.dist-info/RECORD,,
14
+ typecast_python-0.3.5.dist-info/METADATA,sha256=el2lM6XISdSo4t5q84f9bttUey6qPsJ3_iHVT_twXN0,25530
15
+ typecast_python-0.3.5.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ typecast_python-0.3.5.dist-info/licenses/LICENSE,sha256=HvtJ-S89uUkuYmt-OvVk4MRxmzwtbn84__qJtSrGU2Q,11348
17
+ typecast_python-0.3.5.dist-info/RECORD,,