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.
- {typecast_python-0.3.9 → typecast_python-0.3.11}/PKG-INFO +8 -6
- {typecast_python-0.3.9 → typecast_python-0.3.11}/pyproject.toml +15 -9
- typecast_python-0.3.11/src/typecast/_httpx_compat.py +191 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/_user_agent.py +11 -5
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/async_client.py +43 -12
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/client.py +37 -8
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/composer.py +46 -104
- {typecast_python-0.3.9 → typecast_python-0.3.11}/.gitignore +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/LICENSE +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/README.md +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/__init__.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/_voice_clone.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/conf.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/exceptions.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/__init__.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/error.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/subscription.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/tts.py +0 -0
- {typecast_python-0.3.9 → typecast_python-0.3.11}/src/typecast/models/voices.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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<
|
|
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<
|
|
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<
|
|
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.
|
|
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.
|
|
37
|
-
"requests>=2.
|
|
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>=
|
|
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>=
|
|
48
|
+
"pytest-asyncio>=1.4.0,<2.0.0",
|
|
48
49
|
"aioresponses>=0.7.6",
|
|
49
|
-
"
|
|
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>=
|
|
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>=
|
|
98
|
+
"pytest-asyncio>=1.4.0,<2.0.0",
|
|
97
99
|
"aioresponses>=0.7.6",
|
|
98
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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[
|
|
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[
|
|
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 = {
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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[
|
|
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":
|
|
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=
|
|
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.
|
|
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,
|
|
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
|