typecast-python 0.3.9__tar.gz → 0.3.11__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 (20) hide show
  1. {typecast_python-0.3.9 → typecast_python-0.3.11}/PKG-INFO +8 -6
  2. {typecast_python-0.3.9 → typecast_python-0.3.11}/pyproject.toml +15 -9
  3. typecast_python-0.3.11/src/typecast/_httpx_compat.py +191 -0
  4. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/_user_agent.py +11 -5
  5. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/async_client.py +43 -12
  6. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/client.py +37 -8
  7. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/composer.py +46 -104
  8. {typecast_python-0.3.9 → typecast_python-0.3.11}/.gitignore +0 -0
  9. {typecast_python-0.3.9 → typecast_python-0.3.11}/LICENSE +0 -0
  10. {typecast_python-0.3.9 → typecast_python-0.3.11}/README.md +0 -0
  11. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/__init__.py +0 -0
  12. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/_voice_clone.py +0 -0
  13. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/conf.py +0 -0
  14. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/exceptions.py +0 -0
  15. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/__init__.py +0 -0
  16. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/error.py +0 -0
  17. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/subscription.py +0 -0
  18. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/tts.py +0 -0
  19. {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/voices.py +0 -0
  20. {typecast_python-0.3.9 → typecast_python-0.3.11}/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.11
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
@@ -227,20 +227,22 @@ Classifier: Programming Language :: Python :: 3.14
227
227
  Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
228
228
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
229
229
  Requires-Python: <3.15,>=3.8
230
- Requires-Dist: aiohttp>=3.9.0
230
+ Requires-Dist: aiohttp>=3.14.1; python_version >= '3.10'
231
+ Requires-Dist: httpx<1,>=0.28.1; python_version < '3.10'
231
232
  Requires-Dist: pydantic>=2.0.0
232
- Requires-Dist: requests>=2.28.0
233
+ Requires-Dist: requests>=2.33.0; python_version >= '3.10'
233
234
  Requires-Dist: typing-extensions>=4.0.0
234
235
  Provides-Extra: dev
235
236
  Requires-Dist: aioresponses>=0.7.6; extra == 'dev'
236
- Requires-Dist: black<25.0.0,>=23.0.0; extra == 'dev'
237
+ Requires-Dist: black<27.0.0,>=26.3.1; extra == 'dev'
237
238
  Requires-Dist: flake8<6.0.0,>=5.0.0; extra == 'dev'
239
+ Requires-Dist: httpx<1.0.0,>=0.28.1; extra == 'dev'
238
240
  Requires-Dist: isort>=5.0.0; extra == 'dev'
239
241
  Requires-Dist: mypy<1.11.0,>=1.0.0; extra == 'dev'
240
- Requires-Dist: pytest-asyncio<1.0.0,>=0.21.0; extra == 'dev'
242
+ Requires-Dist: pytest-asyncio<2.0.0,>=1.4.0; extra == 'dev'
241
243
  Requires-Dist: pytest-cov<6.0.0,>=5.0.0; extra == 'dev'
242
244
  Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
243
- Requires-Dist: pytest<9.0.0,>=7.0.0; extra == 'dev'
245
+ Requires-Dist: pytest<10.0.0,>=9.0.3; extra == 'dev'
244
246
  Description-Content-Type: text/markdown
245
247
 
246
248
  <div align="center">
@@ -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.11"
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"}
@@ -33,20 +33,22 @@ classifiers = [
33
33
  "Operating System :: OS Independent",
34
34
  ]
35
35
  dependencies = [
36
- "aiohttp>=3.9.0",
37
- "requests>=2.28.0",
36
+ "aiohttp>=3.14.1; python_version >= '3.10'",
37
+ "requests>=2.33.0; python_version >= '3.10'",
38
+ "httpx>=0.28.1,<1; python_version < '3.10'",
38
39
  "pydantic>=2.0.0",
39
40
  "typing-extensions>=4.0.0",
40
41
  ]
41
42
 
42
43
  [project.optional-dependencies]
43
44
  dev = [
44
- "pytest>=7.0.0,<9.0.0",
45
+ "pytest>=9.0.3,<10.0.0",
45
46
  "pytest-cov>=5.0.0,<6.0.0",
46
47
  "pytest-mock>=3.14.0",
47
- "pytest-asyncio>=0.21.0,<1.0.0",
48
+ "pytest-asyncio>=1.4.0,<2.0.0",
48
49
  "aioresponses>=0.7.6",
49
- "black>=23.0.0,<25.0.0",
50
+ "httpx>=0.28.1,<1.0.0",
51
+ "black>=26.3.1,<27.0.0",
50
52
  "flake8>=5.0.0,<6.0.0",
51
53
  "mypy>=1.0.0,<1.11.0",
52
54
  "isort>=5.0.0",
@@ -90,15 +92,19 @@ multi_line_output = 3
90
92
 
91
93
  [dependency-groups]
92
94
  dev = [
93
- "pytest>=7.0.0,<9.0.0",
95
+ "pytest>=9.0.3,<10.0.0",
94
96
  "pytest-cov>=5.0.0,<6.0.0",
95
97
  "pytest-mock>=3.14.0",
96
- "pytest-asyncio>=0.21.0,<1.0.0",
98
+ "pytest-asyncio>=1.4.0,<2.0.0",
97
99
  "aioresponses>=0.7.6",
98
- "python-dotenv>=1.0.0,<2.0.0",
100
+ "httpx>=0.28.1,<1.0.0",
101
+ "python-dotenv>=1.2.2,<2.0.0",
99
102
  "ruff>=0.14.0",
100
103
  ]
101
104
 
105
+ [tool.uv]
106
+ environments = ["python_version >= '3.10'"]
107
+
102
108
  [tool.pytest.ini_options]
103
109
  asyncio_mode = "auto"
104
110
  testpaths = ["tests"]
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple
4
+
5
+ import httpx
6
+
7
+
8
+ def _timeout(value: Any) -> Any:
9
+ if isinstance(value, ClientTimeout):
10
+ total = value.total or value.sock_read or 5.0
11
+ return httpx.Timeout(
12
+ total, connect=value.connect or value.sock_connect or total
13
+ )
14
+ if isinstance(value, tuple):
15
+ return httpx.Timeout(value[1], connect=value[0])
16
+ return value
17
+
18
+
19
+ def _headers(defaults: Dict[str, str], supplied: Optional[dict]) -> Dict[str, str]:
20
+ headers = dict(defaults)
21
+ for key, value in (supplied or {}).items():
22
+ if value is None:
23
+ headers.pop(key, None)
24
+ else:
25
+ headers[key] = value
26
+ return headers
27
+
28
+
29
+ class RequestsCompatResponse:
30
+ def __init__(self, response: httpx.Response):
31
+ self._response = response
32
+
33
+ def __getattr__(self, name: str) -> Any:
34
+ return getattr(self._response, name)
35
+
36
+ def iter_content(self, chunk_size: int) -> Iterator[bytes]:
37
+ return self._response.iter_bytes(chunk_size)
38
+
39
+ def close(self) -> None:
40
+ self._response.close()
41
+
42
+
43
+ class RequestsCompatSession:
44
+ def __init__(self, client: Optional[httpx.Client] = None):
45
+ self._client = client or httpx.Client(timeout=None)
46
+ self.headers: Dict[str, str] = {}
47
+
48
+ def _request(self, method: str, url: str, **kwargs: Any) -> RequestsCompatResponse:
49
+ stream = kwargs.pop("stream", False)
50
+ kwargs["headers"] = _headers(self.headers, kwargs.get("headers"))
51
+ if "timeout" in kwargs:
52
+ kwargs["timeout"] = _timeout(kwargs["timeout"])
53
+ if stream:
54
+ request = self._client.build_request(method, url, **kwargs)
55
+ response = self._client.send(request, stream=True)
56
+ else:
57
+ response = self._client.request(method, url, **kwargs)
58
+ return RequestsCompatResponse(response)
59
+
60
+ def get(self, url: str, **kwargs: Any) -> RequestsCompatResponse:
61
+ return self._request("GET", url, **kwargs)
62
+
63
+ def post(self, url: str, **kwargs: Any) -> RequestsCompatResponse:
64
+ return self._request("POST", url, **kwargs)
65
+
66
+ def delete(self, url: str, **kwargs: Any) -> RequestsCompatResponse:
67
+ return self._request("DELETE", url, **kwargs)
68
+
69
+ def close(self) -> None:
70
+ self._client.close()
71
+
72
+
73
+ class ClientTimeout:
74
+ def __init__(
75
+ self,
76
+ *,
77
+ total: Optional[float] = None,
78
+ connect: Optional[float] = None,
79
+ sock_connect: Optional[float] = None,
80
+ sock_read: Optional[float] = None,
81
+ ):
82
+ self.total = total
83
+ self.connect = connect
84
+ self.sock_connect = sock_connect
85
+ self.sock_read = sock_read
86
+
87
+
88
+ class FormData:
89
+ def __init__(self):
90
+ self.fields: List[Tuple[str, Any, Optional[str], Optional[str]]] = []
91
+
92
+ def add_field(
93
+ self,
94
+ name: str,
95
+ value: Any,
96
+ *,
97
+ filename: Optional[str] = None,
98
+ content_type: Optional[str] = None,
99
+ ) -> None:
100
+ self.fields.append((name, value, filename, content_type))
101
+
102
+
103
+ class _AsyncContent:
104
+ def __init__(self, response: httpx.Response):
105
+ self._response = response
106
+
107
+ async def iter_chunked(self, chunk_size: int) -> AsyncIterator[bytes]:
108
+ async for chunk in self._response.aiter_bytes(chunk_size):
109
+ yield chunk
110
+
111
+
112
+ class AiohttpCompatResponse:
113
+ def __init__(self, response: httpx.Response):
114
+ self._response = response
115
+ self.status = response.status_code
116
+ self.headers = response.headers
117
+ self.content = _AsyncContent(response)
118
+
119
+ async def read(self) -> bytes:
120
+ return await self._response.aread()
121
+
122
+ async def text(self) -> str:
123
+ await self._response.aread()
124
+ return self._response.text
125
+
126
+ async def json(self) -> Any:
127
+ await self._response.aread()
128
+ return self._response.json()
129
+
130
+ async def close(self) -> None:
131
+ await self._response.aclose()
132
+
133
+
134
+ class _AsyncRequestContext:
135
+ def __init__(self, request: Any):
136
+ self._request = request
137
+ self._response: Optional[AiohttpCompatResponse] = None
138
+
139
+ async def __aenter__(self) -> AiohttpCompatResponse:
140
+ response = await self._request
141
+ self._response = response
142
+ return response
143
+
144
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
145
+ if self._response:
146
+ await self._response.close()
147
+
148
+
149
+ class AiohttpCompatSession:
150
+ def __init__(
151
+ self,
152
+ headers: Optional[dict] = None,
153
+ client: Optional[httpx.AsyncClient] = None,
154
+ ):
155
+ self._client = client or httpx.AsyncClient(timeout=300)
156
+ self.headers = dict(headers or {})
157
+
158
+ async def _request(
159
+ self, method: str, url: str, **kwargs: Any
160
+ ) -> AiohttpCompatResponse:
161
+ kwargs["headers"] = _headers(self.headers, kwargs.get("headers"))
162
+ if "timeout" in kwargs:
163
+ kwargs["timeout"] = _timeout(kwargs["timeout"])
164
+ form = kwargs.get("data")
165
+ if isinstance(form, FormData):
166
+ kwargs.pop("data")
167
+ kwargs["data"] = {
168
+ name: value
169
+ for name, value, filename, _ in form.fields
170
+ if filename is None
171
+ }
172
+ kwargs["files"] = [
173
+ (name, (filename, value, content_type))
174
+ for name, value, filename, content_type in form.fields
175
+ if filename is not None
176
+ ]
177
+ request = self._client.build_request(method, url, **kwargs)
178
+ response = await self._client.send(request, stream=True)
179
+ return AiohttpCompatResponse(response)
180
+
181
+ def get(self, url: str, **kwargs: Any) -> _AsyncRequestContext:
182
+ return _AsyncRequestContext(self._request("GET", url, **kwargs))
183
+
184
+ def post(self, url: str, **kwargs: Any) -> _AsyncRequestContext:
185
+ return _AsyncRequestContext(self._request("POST", url, **kwargs))
186
+
187
+ def delete(self, url: str, **kwargs: Any) -> _AsyncRequestContext:
188
+ return _AsyncRequestContext(self._request("DELETE", url, **kwargs))
189
+
190
+ async def close(self) -> None:
191
+ await self._client.aclose()
@@ -2,9 +2,6 @@ import platform
2
2
  import sys
3
3
  from importlib import metadata
4
4
 
5
- import aiohttp
6
- import requests
7
-
8
5
  from . import conf
9
6
 
10
7
 
@@ -55,7 +52,7 @@ def build_user_agent(
55
52
  def requests_user_agent(host: str, transport: str = "rest") -> str:
56
53
  return build_user_agent(
57
54
  mode="sync",
58
- http_library=f"requests/{requests.__version__}",
55
+ http_library=f"requests/{_package_version('requests')}",
59
56
  host=host,
60
57
  transport=transport,
61
58
  )
@@ -64,7 +61,16 @@ def requests_user_agent(host: str, transport: str = "rest") -> str:
64
61
  def aiohttp_user_agent(host: str, transport: str = "rest") -> str:
65
62
  return build_user_agent(
66
63
  mode="async",
67
- http_library=f"aiohttp/{aiohttp.__version__}",
64
+ http_library=f"aiohttp/{_package_version('aiohttp')}",
65
+ host=host,
66
+ transport=transport,
67
+ )
68
+
69
+
70
+ def httpx_user_agent(host: str, mode: str, transport: str = "rest") -> str:
71
+ return build_user_agent(
72
+ mode=mode,
73
+ http_library=f"httpx/{_package_version('httpx')}",
68
74
  host=host,
69
75
  transport=transport,
70
76
  )
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import sys
4
5
  from pathlib import Path
5
- from typing import AsyncIterator, BinaryIO, Optional, Union
6
+ from typing import TYPE_CHECKING, Any, AsyncIterator, BinaryIO, Optional, Union
6
7
  from urllib.parse import quote
7
8
 
8
- import aiohttp
9
+ if sys.version_info >= (3, 10): # pragma: no cover - version-specific import
10
+ import aiohttp
11
+ else: # pragma: no cover
12
+ aiohttp = None # type: ignore[assignment]
9
13
 
10
14
  from . import conf
11
15
  from ._voice_clone import (
@@ -13,7 +17,10 @@ from ._voice_clone import (
13
17
  validate_clone_inputs,
14
18
  validate_custom_voice_id,
15
19
  )
16
- from ._user_agent import aiohttp_user_agent
20
+ from ._user_agent import aiohttp_user_agent, httpx_user_agent
21
+
22
+ if TYPE_CHECKING or sys.version_info < (3, 10): # pragma: no cover
23
+ from ._httpx_compat import AiohttpCompatSession, ClientTimeout, FormData
17
24
  from .client import _guess_audio_mime
18
25
  from .client import _output_with_inferred_format
19
26
  from .client import _validate_output_path
@@ -69,7 +76,7 @@ class AsyncTypecast:
69
76
  self,
70
77
  host: Optional[str] = None,
71
78
  api_key: Optional[str] = None,
72
- session: Optional[aiohttp.ClientSession] = None,
79
+ session: Optional[Any] = None,
73
80
  ):
74
81
  """Initialize the async Typecast client.
75
82
 
@@ -91,16 +98,26 @@ class AsyncTypecast:
91
98
  if not self.api_key and conf.is_default_host(self.host):
92
99
  raise ValueError("API key is required for the default Typecast API host")
93
100
  self._owns_session = session is None
94
- self.session: Optional[aiohttp.ClientSession] = session
101
+ self.session: Optional[Any] = session
95
102
 
96
103
  async def __aenter__(self):
97
104
  # When an external session is injected, do not create a new one.
98
105
  # Per-request auth headers are attached via _request_headers() on each call.
99
106
  if self.session is None:
100
- headers = {"User-Agent": aiohttp_user_agent(self.host)}
107
+ headers = {
108
+ "User-Agent": (
109
+ aiohttp_user_agent(self.host)
110
+ if aiohttp
111
+ else httpx_user_agent(self.host, "async")
112
+ )
113
+ }
101
114
  if self.api_key:
102
115
  headers["X-API-KEY"] = self.api_key
103
- self.session = aiohttp.ClientSession(headers=headers)
116
+ self.session = (
117
+ aiohttp.ClientSession(headers=headers)
118
+ if aiohttp
119
+ else AiohttpCompatSession(headers=headers)
120
+ )
104
121
  return self
105
122
 
106
123
  async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -247,7 +264,11 @@ class AsyncTypecast:
247
264
  if not self.session:
248
265
  raise TypecastError("Client session not initialized. Use async with.")
249
266
  endpoint = "/v1/text-to-speech/stream"
250
- stream_timeout = aiohttp.ClientTimeout(sock_connect=10, sock_read=300)
267
+ stream_timeout = (
268
+ aiohttp.ClientTimeout(sock_connect=10, sock_read=300)
269
+ if aiohttp
270
+ else ClientTimeout(sock_connect=10, sock_read=300)
271
+ )
251
272
  async with self.session.post(
252
273
  f"{self.host}{endpoint}",
253
274
  json=request.model_dump(exclude_none=True),
@@ -258,7 +279,9 @@ class AsyncTypecast:
258
279
  error_text = await response.text()
259
280
  self._handle_error(response.status, error_text)
260
281
 
261
- async for chunk in response.content.iter_chunked(chunk_size): # pragma: no branch
282
+ async for chunk in response.content.iter_chunked(
283
+ chunk_size
284
+ ): # pragma: no branch
262
285
  yield chunk
263
286
 
264
287
  async def text_to_speech_with_timestamps(
@@ -332,7 +355,7 @@ class AsyncTypecast:
332
355
  audio_bytes, filename = validate_clone_inputs(audio, name)
333
356
  model_str = normalize_clone_model(model)
334
357
 
335
- form = aiohttp.FormData()
358
+ form: Any = aiohttp.FormData() if aiohttp else FormData()
336
359
  form.add_field("name", name)
337
360
  form.add_field("model", model_str)
338
361
  form.add_field(
@@ -341,7 +364,11 @@ class AsyncTypecast:
341
364
  filename=filename,
342
365
  content_type=_guess_audio_mime(filename),
343
366
  )
344
- timeout = aiohttp.ClientTimeout(total=300, connect=10)
367
+ timeout = (
368
+ aiohttp.ClientTimeout(total=300, connect=10)
369
+ if aiohttp
370
+ else ClientTimeout(total=300, connect=10)
371
+ )
345
372
  async with self.session.post(
346
373
  f"{self.host}/v1/voices/clone",
347
374
  data=form,
@@ -367,7 +394,11 @@ class AsyncTypecast:
367
394
  raise TypecastError("Client session not initialized; use 'async with'.")
368
395
 
369
396
  validate_custom_voice_id(voice_id)
370
- timeout = aiohttp.ClientTimeout(total=60, connect=10)
397
+ timeout = (
398
+ aiohttp.ClientTimeout(total=60, connect=10)
399
+ if aiohttp
400
+ else ClientTimeout(total=60, connect=10)
401
+ )
371
402
  async with self.session.delete(
372
403
  f"{self.host}/v1/voices/{quote(voice_id, safe='')}",
373
404
  timeout=timeout,
@@ -1,10 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import sys
3
4
  from pathlib import Path
4
- from typing import BinaryIO, Iterator, Optional, Union
5
+ from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Optional, Union
5
6
  from urllib.parse import quote
6
7
 
7
- import requests
8
+ if sys.version_info >= (3, 10): # pragma: no cover - version-specific import
9
+ import requests
10
+ else: # pragma: no cover
11
+ requests = None # type: ignore[assignment]
8
12
 
9
13
  from . import conf
10
14
  from ._voice_clone import (
@@ -12,7 +16,10 @@ from ._voice_clone import (
12
16
  validate_clone_inputs,
13
17
  validate_custom_voice_id,
14
18
  )
15
- from ._user_agent import requests_user_agent
19
+ from ._user_agent import httpx_user_agent, requests_user_agent
20
+
21
+ if TYPE_CHECKING or sys.version_info < (3, 10): # pragma: no cover
22
+ from ._httpx_compat import RequestsCompatSession
16
23
  from .composer import SpeechComposer
17
24
  from .exceptions import (
18
25
  BadRequestError,
@@ -103,7 +110,7 @@ class Typecast:
103
110
  self,
104
111
  host: Optional[str] = None,
105
112
  api_key: Optional[str] = None,
106
- session: Optional[requests.Session] = None,
113
+ session: Optional[Any] = None,
107
114
  ):
108
115
  """Initialize the Typecast client.
109
116
 
@@ -124,13 +131,18 @@ class Typecast:
124
131
  if not self.api_key and conf.is_default_host(self.host):
125
132
  raise ValueError("API key is required for the default Typecast API host")
126
133
  self._owns_session = session is None
134
+ self.session: Any
127
135
  if session is not None:
128
136
  self.session = session
129
137
  else:
130
- self.session = requests.Session()
138
+ self.session = requests.Session() if requests else RequestsCompatSession()
131
139
  headers = {
132
140
  "Content-Type": "application/json",
133
- "User-Agent": requests_user_agent(self.host),
141
+ "User-Agent": (
142
+ requests_user_agent(self.host)
143
+ if requests
144
+ else httpx_user_agent(self.host, "sync")
145
+ ),
134
146
  }
135
147
  if self.api_key:
136
148
  headers["X-API-KEY"] = self.api_key
@@ -197,10 +209,11 @@ class Typecast:
197
209
  if response.status_code != 200:
198
210
  self._handle_error(response.status_code, response.text)
199
211
 
212
+ content_type = response.headers.get("Content-Type", "audio/wav").lower()
200
213
  return TTSResponse(
201
214
  audio_data=response.content,
202
215
  duration=response.headers.get("X-Audio-Duration", 0),
203
- format=response.headers.get("Content-Type", "audio/wav").split("/")[-1],
216
+ format="mp3" if "mp3" in content_type or "mpeg" in content_type else "wav",
204
217
  )
205
218
 
206
219
  def compose_speech(self) -> SpeechComposer:
@@ -209,7 +222,23 @@ class Typecast:
209
222
  Text passed to ``say()`` may include pause markup such as ``<|0.3s|>``.
210
223
  ``pause(seconds)`` also uses seconds, e.g. ``0.3`` for 300 ms.
211
224
  """
212
- return SpeechComposer(self.text_to_speech)
225
+ return SpeechComposer(self.compose_text_to_speech)
226
+
227
+ def compose_text_to_speech(self, segments: list[dict]) -> TTSResponse:
228
+ response = self.session.post(
229
+ f"{self.host}/v1/text-to-speech/compose",
230
+ json={"segments": segments},
231
+ headers=self._request_headers(),
232
+ timeout=(10, 300),
233
+ )
234
+ if response.status_code != 200:
235
+ self._handle_error(response.status_code, response.text)
236
+ content_type = response.headers.get("Content-Type", "audio/wav").lower()
237
+ return TTSResponse(
238
+ audio_data=response.content,
239
+ duration=response.headers.get("X-Audio-Duration", 0),
240
+ format="mp3" if "mp3" in content_type or "mpeg" in content_type else "wav",
241
+ )
213
242
 
214
243
  def generate_to_file(
215
244
  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)