livekit-plugins-vakyam 1.8.0__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.
- livekit_plugins_vakyam-1.8.0/.gitignore +180 -0
- livekit_plugins_vakyam-1.8.0/PKG-INFO +80 -0
- livekit_plugins_vakyam-1.8.0/README.md +56 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/__init__.py +45 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/_utils.py +214 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/_websocket.py +384 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/log.py +3 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/models.py +42 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/py.typed +0 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/tts.py +512 -0
- livekit_plugins_vakyam-1.8.0/livekit/plugins/vakyam/version.py +15 -0
- livekit_plugins_vakyam-1.8.0/pyproject.toml +56 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
**/.vscode
|
|
2
|
+
**/.DS_Store
|
|
3
|
+
.env
|
|
4
|
+
|
|
5
|
+
# Byte-compiled / optimized / DLL files
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*$py.class
|
|
9
|
+
|
|
10
|
+
# C extensions
|
|
11
|
+
*.so
|
|
12
|
+
|
|
13
|
+
# Distribution / packaging
|
|
14
|
+
.Python
|
|
15
|
+
build/
|
|
16
|
+
develop-eggs/
|
|
17
|
+
dist/
|
|
18
|
+
downloads/
|
|
19
|
+
eggs/
|
|
20
|
+
.eggs/
|
|
21
|
+
lib/
|
|
22
|
+
lib64/
|
|
23
|
+
parts/
|
|
24
|
+
sdist/
|
|
25
|
+
var/
|
|
26
|
+
wheels/
|
|
27
|
+
share/python-wheels/
|
|
28
|
+
*.egg-info/
|
|
29
|
+
.installed.cfg
|
|
30
|
+
*.egg
|
|
31
|
+
MANIFEST
|
|
32
|
+
|
|
33
|
+
# PyInstaller
|
|
34
|
+
# Usually these files are written by a python script from a template
|
|
35
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
36
|
+
*.manifest
|
|
37
|
+
*.spec
|
|
38
|
+
|
|
39
|
+
# Installer logs
|
|
40
|
+
pip-log.txt
|
|
41
|
+
pip-delete-this-directory.txt
|
|
42
|
+
|
|
43
|
+
# Unit test / coverage reports
|
|
44
|
+
htmlcov/
|
|
45
|
+
.tox/
|
|
46
|
+
.nox/
|
|
47
|
+
.coverage
|
|
48
|
+
.coverage.*
|
|
49
|
+
.cache
|
|
50
|
+
nosetests.xml
|
|
51
|
+
coverage.xml
|
|
52
|
+
*.cover
|
|
53
|
+
*.py,cover
|
|
54
|
+
.hypothesis/
|
|
55
|
+
.pytest_cache/
|
|
56
|
+
cover/
|
|
57
|
+
|
|
58
|
+
# Translations
|
|
59
|
+
*.mo
|
|
60
|
+
*.pot
|
|
61
|
+
|
|
62
|
+
# Django stuff:
|
|
63
|
+
*.log
|
|
64
|
+
local_settings.py
|
|
65
|
+
db.sqlite3
|
|
66
|
+
db.sqlite3-journal
|
|
67
|
+
|
|
68
|
+
# Flask stuff:
|
|
69
|
+
instance/
|
|
70
|
+
.webassets-cache
|
|
71
|
+
|
|
72
|
+
# Scrapy stuff:
|
|
73
|
+
.scrapy
|
|
74
|
+
|
|
75
|
+
# Sphinx documentation
|
|
76
|
+
docs/_build/
|
|
77
|
+
|
|
78
|
+
# PyBuilder
|
|
79
|
+
.pybuilder/
|
|
80
|
+
target/
|
|
81
|
+
|
|
82
|
+
# Jupyter Notebook
|
|
83
|
+
.ipynb_checkpoints
|
|
84
|
+
|
|
85
|
+
# IPython
|
|
86
|
+
profile_default/
|
|
87
|
+
ipython_config.py
|
|
88
|
+
|
|
89
|
+
# pyenv
|
|
90
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
91
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
92
|
+
# .python-version
|
|
93
|
+
|
|
94
|
+
# pipenv
|
|
95
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
96
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
97
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
98
|
+
# install all needed dependencies.
|
|
99
|
+
#Pipfile.lock
|
|
100
|
+
|
|
101
|
+
# poetry
|
|
102
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
103
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
104
|
+
# commonly ignored for libraries.
|
|
105
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
106
|
+
#poetry.lock
|
|
107
|
+
|
|
108
|
+
# pdm
|
|
109
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
110
|
+
#pdm.lock
|
|
111
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
112
|
+
# in version control.
|
|
113
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
114
|
+
.pdm.toml
|
|
115
|
+
|
|
116
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
117
|
+
__pypackages__/
|
|
118
|
+
|
|
119
|
+
# Celery stuff
|
|
120
|
+
celerybeat-schedule
|
|
121
|
+
celerybeat.pid
|
|
122
|
+
|
|
123
|
+
# SageMath parsed files
|
|
124
|
+
*.sage.py
|
|
125
|
+
|
|
126
|
+
# Environments
|
|
127
|
+
.env
|
|
128
|
+
.venv
|
|
129
|
+
env/
|
|
130
|
+
venv/
|
|
131
|
+
ENV/
|
|
132
|
+
env.bak/
|
|
133
|
+
venv.bak/
|
|
134
|
+
|
|
135
|
+
# Spyder project settings
|
|
136
|
+
.spyderproject
|
|
137
|
+
.spyproject
|
|
138
|
+
|
|
139
|
+
# Rope project settings
|
|
140
|
+
.ropeproject
|
|
141
|
+
|
|
142
|
+
# mkdocs documentation
|
|
143
|
+
/site
|
|
144
|
+
|
|
145
|
+
# mypy
|
|
146
|
+
.mypy_cache/
|
|
147
|
+
.dmypy.json
|
|
148
|
+
dmypy.json
|
|
149
|
+
|
|
150
|
+
# trunk
|
|
151
|
+
.trunk/
|
|
152
|
+
|
|
153
|
+
# Pyre type checker
|
|
154
|
+
.pyre/
|
|
155
|
+
|
|
156
|
+
# pytype static type analyzer
|
|
157
|
+
.pytype/
|
|
158
|
+
|
|
159
|
+
# Cython debug symbols
|
|
160
|
+
cython_debug/
|
|
161
|
+
|
|
162
|
+
# PyCharm
|
|
163
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
164
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
165
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
166
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
167
|
+
.idea/
|
|
168
|
+
|
|
169
|
+
node_modules
|
|
170
|
+
|
|
171
|
+
credentials.json
|
|
172
|
+
pyrightconfig.json
|
|
173
|
+
docs/
|
|
174
|
+
|
|
175
|
+
# Database files
|
|
176
|
+
*.db
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Examples for development
|
|
180
|
+
examples/dev/*
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: livekit-plugins-vakyam
|
|
3
|
+
Version: 1.8.0
|
|
4
|
+
Summary: LiveKit Agents plugin for Vakyam AI TTS (Raaga 1) — Indian-language text-to-speech
|
|
5
|
+
Project-URL: Documentation, https://docs.livekit.io
|
|
6
|
+
Project-URL: Website, https://livekit.io/
|
|
7
|
+
Project-URL: Source, https://github.com/livekit/agents
|
|
8
|
+
Author-email: LiveKit <hello@livekit.io>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: audio,indian-languages,livekit,raaga,realtime,text-to-speech,tts,vakyam,webrtc
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10.0
|
|
21
|
+
Requires-Dist: livekit-agents[codecs]>=1.8.0
|
|
22
|
+
Requires-Dist: websockets<16.0,>=14.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Vakyam AI plugin for LiveKit Agents
|
|
26
|
+
|
|
27
|
+
Support for voice synthesis with [Vakyam AI](https://vakyam.ai/) Raaga 1 —
|
|
28
|
+
text-to-speech for Indian languages.
|
|
29
|
+
|
|
30
|
+
See [https://docs.vakyam.ai/integrations/livekit](https://docs.vakyam.ai/integrations/livekit)
|
|
31
|
+
for provider docs.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install livekit-plugins-vakyam
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or with the LiveKit Agents extra:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add "livekit-agents[vakyam]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Pre-requisites
|
|
46
|
+
|
|
47
|
+
You'll need an API key from [Vakyam](https://dashboard.vakyam.ai/api-keys).
|
|
48
|
+
Set it as an environment variable:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
export VAKYAM_API_KEY="vak_live_..."
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from livekit.agents import AgentSession
|
|
58
|
+
from livekit.plugins import vakyam
|
|
59
|
+
|
|
60
|
+
session = AgentSession(
|
|
61
|
+
tts=vakyam.TTS(
|
|
62
|
+
model="raaga-v1",
|
|
63
|
+
voice="Archana",
|
|
64
|
+
language="ta-IN",
|
|
65
|
+
sample_rate=24000,
|
|
66
|
+
),
|
|
67
|
+
# ... stt, llm, vad
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`stream()` uses the realtime WebSocket API and sentence-tokenizes LLM text so
|
|
72
|
+
each utterance is one complete sentence (Vakyam does not accept partial
|
|
73
|
+
tokens). `synthesize()` uses HTTP streaming (`POST /v1/tts/stream`) and
|
|
74
|
+
returns PCM audio.
|
|
75
|
+
|
|
76
|
+
WebSocket connections are pooled and reused between sequential agent turns.
|
|
77
|
+
Each active synthesis stream has exclusive ownership of its connection, so an
|
|
78
|
+
overlapping stream uses a separate connection. On interruption, the plugin
|
|
79
|
+
sends `cancel`, drains through Vakyam's cancellation acknowledgement, and
|
|
80
|
+
returns the healthy connection to the pool.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Vakyam AI plugin for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Support for voice synthesis with [Vakyam AI](https://vakyam.ai/) Raaga 1 —
|
|
4
|
+
text-to-speech for Indian languages.
|
|
5
|
+
|
|
6
|
+
See [https://docs.vakyam.ai/integrations/livekit](https://docs.vakyam.ai/integrations/livekit)
|
|
7
|
+
for provider docs.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install livekit-plugins-vakyam
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or with the LiveKit Agents extra:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add "livekit-agents[vakyam]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Pre-requisites
|
|
22
|
+
|
|
23
|
+
You'll need an API key from [Vakyam](https://dashboard.vakyam.ai/api-keys).
|
|
24
|
+
Set it as an environment variable:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export VAKYAM_API_KEY="vak_live_..."
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from livekit.agents import AgentSession
|
|
34
|
+
from livekit.plugins import vakyam
|
|
35
|
+
|
|
36
|
+
session = AgentSession(
|
|
37
|
+
tts=vakyam.TTS(
|
|
38
|
+
model="raaga-v1",
|
|
39
|
+
voice="Archana",
|
|
40
|
+
language="ta-IN",
|
|
41
|
+
sample_rate=24000,
|
|
42
|
+
),
|
|
43
|
+
# ... stt, llm, vad
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`stream()` uses the realtime WebSocket API and sentence-tokenizes LLM text so
|
|
48
|
+
each utterance is one complete sentence (Vakyam does not accept partial
|
|
49
|
+
tokens). `synthesize()` uses HTTP streaming (`POST /v1/tts/stream`) and
|
|
50
|
+
returns PCM audio.
|
|
51
|
+
|
|
52
|
+
WebSocket connections are pooled and reused between sequential agent turns.
|
|
53
|
+
Each active synthesis stream has exclusive ownership of its connection, so an
|
|
54
|
+
overlapping stream uses a separate connection. On interruption, the plugin
|
|
55
|
+
sends `cancel`, drains through Vakyam's cancellation acknowledgement, and
|
|
56
|
+
returns the healthy connection to the pool.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2025 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Vakyam AI plugin for LiveKit Agents
|
|
16
|
+
|
|
17
|
+
Support for text-to-speech with [Vakyam AI](https://vakyam.ai/) Raaga 1.
|
|
18
|
+
|
|
19
|
+
See https://docs.vakyam.ai/guides/realtime-websocket for protocol details.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .tts import TTS, ChunkedStream, SynthesizeStream
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
__all__ = ["TTS", "ChunkedStream", "SynthesizeStream", "__version__"]
|
|
26
|
+
|
|
27
|
+
from livekit.agents import Plugin
|
|
28
|
+
|
|
29
|
+
from .log import logger
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class VakyamPlugin(Plugin):
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Plugin.register_plugin(VakyamPlugin())
|
|
38
|
+
|
|
39
|
+
_module = dir()
|
|
40
|
+
NOT_IN_ALL = [m for m in _module if m not in __all__]
|
|
41
|
+
|
|
42
|
+
__pdoc__ = {}
|
|
43
|
+
|
|
44
|
+
for n in NOT_IN_ALL:
|
|
45
|
+
__pdoc__[n] = False
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# Copyright 2025 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from livekit.agents import APIStatusError
|
|
22
|
+
|
|
23
|
+
from .models import (
|
|
24
|
+
CUSTOM_VOICE_PREFIX,
|
|
25
|
+
MAX_SPEED,
|
|
26
|
+
MAX_TEXT_CHARACTERS,
|
|
27
|
+
MIN_SPEED,
|
|
28
|
+
SUPPORTED_LANGUAGES,
|
|
29
|
+
SUPPORTED_MODELS,
|
|
30
|
+
SUPPORTED_SAMPLE_RATES,
|
|
31
|
+
TTS_STREAM_PATH,
|
|
32
|
+
TTS_WEBSOCKET_PATH,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_RETRYABLE_WS_CODES = {
|
|
36
|
+
"rate_limit_exceeded",
|
|
37
|
+
"concurrency_limit_exceeded",
|
|
38
|
+
"tts_workers_busy",
|
|
39
|
+
"tts_workers_unconfigured",
|
|
40
|
+
"tts_workers_unavailable",
|
|
41
|
+
"internal_error",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_voice(voice: str) -> str:
|
|
46
|
+
"""Normalize a preset voice name or custom ``vc_`` voice ID."""
|
|
47
|
+
if not isinstance(voice, str) or not voice.strip():
|
|
48
|
+
raise ValueError("voice must be a non-empty string")
|
|
49
|
+
normalized = voice.strip()
|
|
50
|
+
if normalized == CUSTOM_VOICE_PREFIX:
|
|
51
|
+
raise ValueError("custom voice IDs must include a value after 'vc_'")
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_base_url(base_url: str, *, allow_insecure_base_url: bool = False) -> str:
|
|
56
|
+
"""Normalize and validate a public API base URL."""
|
|
57
|
+
normalized = base_url.rstrip("/")
|
|
58
|
+
parsed = urlparse(normalized)
|
|
59
|
+
if parsed.scheme != "http":
|
|
60
|
+
return normalized
|
|
61
|
+
|
|
62
|
+
host = parsed.hostname or ""
|
|
63
|
+
if allow_insecure_base_url or host in {"localhost", "127.0.0.1", "::1"}:
|
|
64
|
+
return normalized
|
|
65
|
+
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"base_url must use HTTPS unless it points to localhost. "
|
|
68
|
+
"Pass allow_insecure_base_url=True only for trusted development networks."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def websocket_url(base_url: str) -> str:
|
|
73
|
+
"""Build the Vakyam TTS WebSocket URL from an HTTP(S) or WS(S) base URL."""
|
|
74
|
+
root = base_url.rstrip("/")
|
|
75
|
+
if root.startswith("https://"):
|
|
76
|
+
root = "wss://" + root[len("https://") :]
|
|
77
|
+
elif root.startswith("http://"):
|
|
78
|
+
root = "ws://" + root[len("http://") :]
|
|
79
|
+
elif not root.startswith(("ws://", "wss://")):
|
|
80
|
+
root = "wss://" + root
|
|
81
|
+
if root.endswith(TTS_WEBSOCKET_PATH):
|
|
82
|
+
return root
|
|
83
|
+
return root + TTS_WEBSOCKET_PATH
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def http_stream_url(base_url: str) -> str:
|
|
87
|
+
"""Build the Vakyam HTTP streaming TTS URL."""
|
|
88
|
+
return base_url.rstrip("/") + TTS_STREAM_PATH
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_tts_options(
|
|
92
|
+
*,
|
|
93
|
+
model: str,
|
|
94
|
+
language: str,
|
|
95
|
+
sample_rate: int,
|
|
96
|
+
speed: float,
|
|
97
|
+
voice: str,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Validate constructor / update_options values."""
|
|
100
|
+
if model not in SUPPORTED_MODELS:
|
|
101
|
+
valid = ", ".join(sorted(SUPPORTED_MODELS))
|
|
102
|
+
raise ValueError(f"model '{model}' is not supported. Valid values are: {valid}.")
|
|
103
|
+
if language not in SUPPORTED_LANGUAGES:
|
|
104
|
+
valid = ", ".join(sorted(SUPPORTED_LANGUAGES))
|
|
105
|
+
raise ValueError(f"language '{language}' is not supported. Valid values are: {valid}.")
|
|
106
|
+
if sample_rate not in SUPPORTED_SAMPLE_RATES:
|
|
107
|
+
valid = ", ".join(str(v) for v in sorted(SUPPORTED_SAMPLE_RATES))
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"sample_rate '{sample_rate}' is not supported. Valid values are: {valid}."
|
|
110
|
+
)
|
|
111
|
+
if not MIN_SPEED <= speed <= MAX_SPEED:
|
|
112
|
+
raise ValueError(f"speed must be between {MIN_SPEED} and {MAX_SPEED}")
|
|
113
|
+
normalize_voice(voice)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_text(text: str) -> None:
|
|
117
|
+
"""Validate utterance text for a single synthesis request."""
|
|
118
|
+
if not isinstance(text, str) or not text:
|
|
119
|
+
raise ValueError("text is required")
|
|
120
|
+
character_count = len(text)
|
|
121
|
+
if character_count > MAX_TEXT_CHARACTERS:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"Input text is {character_count} characters. Maximum allowed is "
|
|
124
|
+
f"{MAX_TEXT_CHARACTERS} Unicode characters."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def split_text(text: str, *, max_characters: int = MAX_TEXT_CHARACTERS) -> list[str]:
|
|
129
|
+
"""Split an oversized utterance at whitespace, falling back to a hard boundary."""
|
|
130
|
+
if max_characters <= 0:
|
|
131
|
+
raise ValueError("max_characters must be greater than zero")
|
|
132
|
+
|
|
133
|
+
remaining = text.strip()
|
|
134
|
+
chunks: list[str] = []
|
|
135
|
+
while len(remaining) > max_characters:
|
|
136
|
+
split_at = remaining.rfind(" ", 0, max_characters + 1)
|
|
137
|
+
if split_at <= 0:
|
|
138
|
+
split_at = max_characters
|
|
139
|
+
chunk = remaining[:split_at].strip()
|
|
140
|
+
if chunk:
|
|
141
|
+
chunks.append(chunk)
|
|
142
|
+
remaining = remaining[split_at:].lstrip()
|
|
143
|
+
if remaining:
|
|
144
|
+
chunks.append(remaining)
|
|
145
|
+
return chunks
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def speech_payload(
|
|
149
|
+
*,
|
|
150
|
+
text: str,
|
|
151
|
+
model: str,
|
|
152
|
+
voice: str,
|
|
153
|
+
language: str,
|
|
154
|
+
sample_rate: int,
|
|
155
|
+
speed: float,
|
|
156
|
+
output_format: str = "pcm",
|
|
157
|
+
) -> dict[str, Any]:
|
|
158
|
+
"""JSON body for HTTP generate/stream requests."""
|
|
159
|
+
validate_text(text)
|
|
160
|
+
validate_tts_options(
|
|
161
|
+
model=model, language=language, sample_rate=sample_rate, speed=speed, voice=voice
|
|
162
|
+
)
|
|
163
|
+
return {
|
|
164
|
+
"text": text,
|
|
165
|
+
"model_id": model,
|
|
166
|
+
"voice": normalize_voice(voice),
|
|
167
|
+
"language": language,
|
|
168
|
+
"output_format": output_format,
|
|
169
|
+
"sample_rate": sample_rate,
|
|
170
|
+
"speed": speed,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def raise_http_error(status: int, body: str) -> None:
|
|
175
|
+
"""Raise ``APIStatusError`` from a Vakyam HTTP error envelope."""
|
|
176
|
+
parsed: object | None = None
|
|
177
|
+
try:
|
|
178
|
+
parsed = json.loads(body) if body else None
|
|
179
|
+
except json.JSONDecodeError:
|
|
180
|
+
parsed = None
|
|
181
|
+
|
|
182
|
+
error_code: str | None = None
|
|
183
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
184
|
+
error = parsed["error"]
|
|
185
|
+
code = error.get("code")
|
|
186
|
+
if isinstance(code, (str, int)):
|
|
187
|
+
error_code = str(code)
|
|
188
|
+
|
|
189
|
+
message = f"Vakyam TTS request failed with status {status}"
|
|
190
|
+
safe_body: dict[str, object] = {"status_code": status}
|
|
191
|
+
if error_code is not None:
|
|
192
|
+
message += f" (error code: {error_code})"
|
|
193
|
+
safe_body["error_code"] = error_code
|
|
194
|
+
|
|
195
|
+
raise APIStatusError(message, status_code=status, body=safe_body)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def raise_ws_error(data: dict[str, Any]) -> None:
|
|
199
|
+
"""Raise ``APIStatusError`` from a Vakyam WebSocket ``error`` frame."""
|
|
200
|
+
error = data.get("error") if isinstance(data.get("error"), dict) else {}
|
|
201
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
202
|
+
error_code = str(code) if isinstance(code, (str, int)) else None
|
|
203
|
+
retryable = error_code in _RETRYABLE_WS_CODES
|
|
204
|
+
message = "Vakyam TTS WebSocket request failed"
|
|
205
|
+
safe_body: dict[str, str] = {"type": "error"}
|
|
206
|
+
if error_code is not None:
|
|
207
|
+
message += f" (error code: {error_code})"
|
|
208
|
+
safe_body["code"] = error_code
|
|
209
|
+
raise APIStatusError(
|
|
210
|
+
message,
|
|
211
|
+
status_code=-1,
|
|
212
|
+
body=safe_body,
|
|
213
|
+
retryable=retryable,
|
|
214
|
+
)
|