livekit-plugins-murf 1.4.6__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_murf-1.4.6/.gitignore +179 -0
- livekit_plugins_murf-1.4.6/PKG-INFO +38 -0
- livekit_plugins_murf-1.4.6/README.md +15 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/__init__.py +45 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/log.py +3 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/models.py +61 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/py.typed +1 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/tts.py +427 -0
- livekit_plugins_murf-1.4.6/livekit/plugins/murf/version.py +15 -0
- livekit_plugins_murf-1.4.6/pyproject.toml +39 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
**/.vscode
|
|
2
|
+
**/.DS_Store
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
# C extensions
|
|
10
|
+
*.so
|
|
11
|
+
|
|
12
|
+
# Distribution / packaging
|
|
13
|
+
.Python
|
|
14
|
+
build/
|
|
15
|
+
develop-eggs/
|
|
16
|
+
dist/
|
|
17
|
+
downloads/
|
|
18
|
+
eggs/
|
|
19
|
+
.eggs/
|
|
20
|
+
lib/
|
|
21
|
+
lib64/
|
|
22
|
+
parts/
|
|
23
|
+
sdist/
|
|
24
|
+
var/
|
|
25
|
+
wheels/
|
|
26
|
+
share/python-wheels/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
.installed.cfg
|
|
29
|
+
*.egg
|
|
30
|
+
MANIFEST
|
|
31
|
+
|
|
32
|
+
# PyInstaller
|
|
33
|
+
# Usually these files are written by a python script from a template
|
|
34
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
35
|
+
*.manifest
|
|
36
|
+
*.spec
|
|
37
|
+
|
|
38
|
+
# Installer logs
|
|
39
|
+
pip-log.txt
|
|
40
|
+
pip-delete-this-directory.txt
|
|
41
|
+
|
|
42
|
+
# Unit test / coverage reports
|
|
43
|
+
htmlcov/
|
|
44
|
+
.tox/
|
|
45
|
+
.nox/
|
|
46
|
+
.coverage
|
|
47
|
+
.coverage.*
|
|
48
|
+
.cache
|
|
49
|
+
nosetests.xml
|
|
50
|
+
coverage.xml
|
|
51
|
+
*.cover
|
|
52
|
+
*.py,cover
|
|
53
|
+
.hypothesis/
|
|
54
|
+
.pytest_cache/
|
|
55
|
+
cover/
|
|
56
|
+
|
|
57
|
+
# Translations
|
|
58
|
+
*.mo
|
|
59
|
+
*.pot
|
|
60
|
+
|
|
61
|
+
# Django stuff:
|
|
62
|
+
*.log
|
|
63
|
+
local_settings.py
|
|
64
|
+
db.sqlite3
|
|
65
|
+
db.sqlite3-journal
|
|
66
|
+
|
|
67
|
+
# Flask stuff:
|
|
68
|
+
instance/
|
|
69
|
+
.webassets-cache
|
|
70
|
+
|
|
71
|
+
# Scrapy stuff:
|
|
72
|
+
.scrapy
|
|
73
|
+
|
|
74
|
+
# Sphinx documentation
|
|
75
|
+
docs/_build/
|
|
76
|
+
|
|
77
|
+
# PyBuilder
|
|
78
|
+
.pybuilder/
|
|
79
|
+
target/
|
|
80
|
+
|
|
81
|
+
# Jupyter Notebook
|
|
82
|
+
.ipynb_checkpoints
|
|
83
|
+
|
|
84
|
+
# IPython
|
|
85
|
+
profile_default/
|
|
86
|
+
ipython_config.py
|
|
87
|
+
|
|
88
|
+
# pyenv
|
|
89
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
90
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
91
|
+
# .python-version
|
|
92
|
+
|
|
93
|
+
# pipenv
|
|
94
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
95
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
96
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
97
|
+
# install all needed dependencies.
|
|
98
|
+
#Pipfile.lock
|
|
99
|
+
|
|
100
|
+
# poetry
|
|
101
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
102
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
103
|
+
# commonly ignored for libraries.
|
|
104
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
105
|
+
#poetry.lock
|
|
106
|
+
|
|
107
|
+
# pdm
|
|
108
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
109
|
+
#pdm.lock
|
|
110
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
111
|
+
# in version control.
|
|
112
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
113
|
+
.pdm.toml
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# trunk
|
|
150
|
+
.trunk/
|
|
151
|
+
|
|
152
|
+
# Pyre type checker
|
|
153
|
+
.pyre/
|
|
154
|
+
|
|
155
|
+
# pytype static type analyzer
|
|
156
|
+
.pytype/
|
|
157
|
+
|
|
158
|
+
# Cython debug symbols
|
|
159
|
+
cython_debug/
|
|
160
|
+
|
|
161
|
+
# PyCharm
|
|
162
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
163
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
164
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
165
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
166
|
+
.idea/
|
|
167
|
+
|
|
168
|
+
node_modules
|
|
169
|
+
|
|
170
|
+
credentials.json
|
|
171
|
+
pyrightconfig.json
|
|
172
|
+
docs/
|
|
173
|
+
|
|
174
|
+
# Database files
|
|
175
|
+
*.db
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Examples for development
|
|
179
|
+
examples/dev/*
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livekit-plugins-murf
|
|
3
|
+
Version: 1.4.6
|
|
4
|
+
Summary: LiveKit Agents Plugin for Murf
|
|
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,livekit,realtime,video,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.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
18
|
+
Classifier: Topic :: Multimedia :: Video
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Requires-Python: >=3.10.0
|
|
21
|
+
Requires-Dist: livekit-agents>=1.4.6
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Murf AI plugin for LiveKit Agents
|
|
25
|
+
|
|
26
|
+
Support for [Murf AI](https://murf.ai/)'s voice AI services in LiveKit Agents.
|
|
27
|
+
|
|
28
|
+
More information is available in the [API docs](https://murf.ai/api/docs).
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install livekit-plugins-murf
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Pre-requisites
|
|
37
|
+
|
|
38
|
+
You'll need an API key from Murf AI. It can be set as an environment variable: `MURF_API_KEY`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Murf AI plugin for LiveKit Agents
|
|
2
|
+
|
|
3
|
+
Support for [Murf AI](https://murf.ai/)'s voice AI services in LiveKit Agents.
|
|
4
|
+
|
|
5
|
+
More information is available in the [API docs](https://murf.ai/api/docs).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install livekit-plugins-murf
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Pre-requisites
|
|
14
|
+
|
|
15
|
+
You'll need an API key from Murf AI. It can be set as an environment variable: `MURF_API_KEY`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2023 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
|
+
"""Murf AI plugin for LiveKit Agents"""
|
|
16
|
+
|
|
17
|
+
from .tts import TTS, ChunkedStream
|
|
18
|
+
from .version import __version__
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"TTS",
|
|
22
|
+
"ChunkedStream",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
from livekit.agents import Plugin
|
|
27
|
+
|
|
28
|
+
from .log import logger
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MurfPlugin(Plugin):
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Plugin.register_plugin(MurfPlugin())
|
|
37
|
+
|
|
38
|
+
# Cleanup docs of unexported modules
|
|
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,61 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
TTSLocales = Literal[
|
|
4
|
+
"en-US",
|
|
5
|
+
"en-UK",
|
|
6
|
+
"en-AU",
|
|
7
|
+
"en-IN",
|
|
8
|
+
"en-SCOTT",
|
|
9
|
+
"es-ES",
|
|
10
|
+
"es-MX",
|
|
11
|
+
"hi-IN",
|
|
12
|
+
"ta-IN",
|
|
13
|
+
"bn-IN",
|
|
14
|
+
"fr-FR",
|
|
15
|
+
"de-DE",
|
|
16
|
+
"it-IT",
|
|
17
|
+
"pt-BR",
|
|
18
|
+
"zh-CN",
|
|
19
|
+
"nl-NL",
|
|
20
|
+
"ja-JP",
|
|
21
|
+
"id-ID",
|
|
22
|
+
"ko-KR",
|
|
23
|
+
"ro-RO",
|
|
24
|
+
"tr-TR",
|
|
25
|
+
"pl-PL",
|
|
26
|
+
"sk-SK",
|
|
27
|
+
"hr-HR",
|
|
28
|
+
"el-GR",
|
|
29
|
+
"bg-BG",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
TTSModels = Literal["GEN2", "FALCON"]
|
|
33
|
+
|
|
34
|
+
TTSStyles = Literal[
|
|
35
|
+
"Promo",
|
|
36
|
+
"Narration",
|
|
37
|
+
"Calm",
|
|
38
|
+
"Conversation",
|
|
39
|
+
"Conversational",
|
|
40
|
+
"Sad",
|
|
41
|
+
"Angry",
|
|
42
|
+
"Sports Commentary",
|
|
43
|
+
"Newscast",
|
|
44
|
+
"Terrified",
|
|
45
|
+
"Inspirational",
|
|
46
|
+
"Customer Support Agent",
|
|
47
|
+
"Narration",
|
|
48
|
+
"Audiobook",
|
|
49
|
+
"Storytelling",
|
|
50
|
+
"Furious",
|
|
51
|
+
"Sobbing",
|
|
52
|
+
"Wizard",
|
|
53
|
+
"Clown",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
TTSEncoding = Literal[
|
|
57
|
+
"pcm", # pcm_s16le
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
TTSDefaultVoiceId = "en-US-matthew"
|
|
61
|
+
TTSDefaultVoiceStyle = "Conversation"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Copyright 2023 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 asyncio
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import weakref
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import aiohttp
|
|
26
|
+
|
|
27
|
+
from livekit.agents import (
|
|
28
|
+
APIConnectionError,
|
|
29
|
+
APIConnectOptions,
|
|
30
|
+
APIStatusError,
|
|
31
|
+
APITimeoutError,
|
|
32
|
+
tokenize,
|
|
33
|
+
tts,
|
|
34
|
+
utils,
|
|
35
|
+
)
|
|
36
|
+
from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr
|
|
37
|
+
from livekit.agents.utils import is_given
|
|
38
|
+
|
|
39
|
+
from .log import logger
|
|
40
|
+
from .models import (
|
|
41
|
+
TTSDefaultVoiceId,
|
|
42
|
+
TTSDefaultVoiceStyle,
|
|
43
|
+
TTSEncoding,
|
|
44
|
+
TTSLocales,
|
|
45
|
+
TTSModels,
|
|
46
|
+
TTSStyles,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
API_AUTH_HEADER = "api-key"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _TTSOptions:
|
|
54
|
+
api_key: str
|
|
55
|
+
locale: TTSLocales | str | None
|
|
56
|
+
model: TTSModels | str
|
|
57
|
+
voice: str
|
|
58
|
+
style: str | None
|
|
59
|
+
speed: int | None
|
|
60
|
+
pitch: int | None
|
|
61
|
+
sample_rate: int
|
|
62
|
+
encoding: TTSEncoding
|
|
63
|
+
base_url: str
|
|
64
|
+
min_buffer_size: int
|
|
65
|
+
max_buffer_delay_in_ms: int
|
|
66
|
+
|
|
67
|
+
def get_http_url(self, path: str) -> str:
|
|
68
|
+
return f"{self.base_url}{path}"
|
|
69
|
+
|
|
70
|
+
def get_ws_url(self, path: str) -> str:
|
|
71
|
+
return f"{self.base_url.replace('http', 'ws', 1)}{path}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TTS(tts.TTS):
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
api_key: str | None = None,
|
|
79
|
+
model: TTSModels | str = "FALCON",
|
|
80
|
+
locale: TTSLocales | str | None = None,
|
|
81
|
+
voice: str = TTSDefaultVoiceId,
|
|
82
|
+
style: TTSStyles | str | None = None,
|
|
83
|
+
speed: int | None = None,
|
|
84
|
+
pitch: int | None = None,
|
|
85
|
+
sample_rate: int = 24000,
|
|
86
|
+
encoding: TTSEncoding = "pcm",
|
|
87
|
+
base_url: str = "https://global.api.murf.ai",
|
|
88
|
+
http_session: aiohttp.ClientSession | None = None,
|
|
89
|
+
tokenizer: NotGivenOr[tokenize.SentenceTokenizer] = NOT_GIVEN,
|
|
90
|
+
text_pacing: tts.SentenceStreamPacer | bool = False,
|
|
91
|
+
min_buffer_size: int = 3,
|
|
92
|
+
max_buffer_delay_in_ms: int = 0,
|
|
93
|
+
streaming: bool = True,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Create a new instance of Murf AI TTS.
|
|
97
|
+
|
|
98
|
+
See https://murf.ai/api/docs/api-reference/text-to-speech/stream-input for more details on the the Murf AI API.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
api_key (str | None, optional): The Murf AI API key. If not provided, it will be read from the MURF_API_KEY environment variable.
|
|
102
|
+
model (TTSModels | str, optional): The Murf AI TTS model to use. Defaults to "FALCON".
|
|
103
|
+
locale (str | None, optional): The locale for synthesis (e.g., "en-US", "en-UK"). If not provided, will be inferred from voice.
|
|
104
|
+
voice (str, optional): The voice ID from Murf AI's voice library (e.g., "en-US-matthew"). Defaults to TTSDefaultVoiceId.
|
|
105
|
+
style (TTSStyles | str | None, optional): The voice style to apply (e.g., "Conversation"). Can be None for default style.
|
|
106
|
+
speed (int | None, optional): The speech speed control. Higher values = faster speech. None for default speed.
|
|
107
|
+
pitch (int | None, optional): The speech pitch control. Higher values = higher pitch. None for default pitch.
|
|
108
|
+
sample_rate (int, optional): The audio sample rate in Hz. Defaults to 24000.
|
|
109
|
+
encoding (str, optional): The audio encoding format. Defaults to "pcm".
|
|
110
|
+
http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. If not provided, a new session will be created.
|
|
111
|
+
base_url (str, optional): The base URL for the Murf AI API. Defaults to "https://global.api.murf.ai".
|
|
112
|
+
tokenizer (tokenize.SentenceTokenizer, optional): The tokenizer to use. Defaults to tokenize.basic.SentenceTokenizer(min_sentence_len=min_buffer_size).
|
|
113
|
+
text_pacing (tts.SentenceStreamPacer | bool, optional): Stream pacer for the TTS. Set to True to use the default pacer, False to disable.
|
|
114
|
+
min_buffer_size (int, optional):Minimum characters to buffer before sending text to audio when no sentence boundary is detected. Higher values improve quality; lower values reduce TTFB. Defaults to 3.
|
|
115
|
+
max_buffer_delay_in_ms (int, optional): Maximum wait time before sending buffered text if min_buffer_size isn’t reached. Defaults to 0.
|
|
116
|
+
streaming (bool, optional): If True, uses WebSocket streaming for real-time audio. If False, uses HTTP requests. Defaults to True.
|
|
117
|
+
""" # noqa: E501
|
|
118
|
+
|
|
119
|
+
self._streaming = streaming
|
|
120
|
+
|
|
121
|
+
super().__init__(
|
|
122
|
+
capabilities=tts.TTSCapabilities(streaming=streaming),
|
|
123
|
+
sample_rate=sample_rate,
|
|
124
|
+
num_channels=1,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
murf_api_key = api_key or os.environ.get("MURF_API_KEY")
|
|
128
|
+
if not murf_api_key:
|
|
129
|
+
raise ValueError("MURF_API_KEY must be set")
|
|
130
|
+
|
|
131
|
+
self._opts = _TTSOptions(
|
|
132
|
+
api_key=murf_api_key,
|
|
133
|
+
model=model,
|
|
134
|
+
locale=locale,
|
|
135
|
+
voice=voice,
|
|
136
|
+
style=style or TTSDefaultVoiceStyle,
|
|
137
|
+
speed=speed,
|
|
138
|
+
pitch=pitch,
|
|
139
|
+
sample_rate=sample_rate,
|
|
140
|
+
encoding=encoding,
|
|
141
|
+
base_url=base_url,
|
|
142
|
+
min_buffer_size=min_buffer_size,
|
|
143
|
+
max_buffer_delay_in_ms=max_buffer_delay_in_ms,
|
|
144
|
+
)
|
|
145
|
+
self._session = http_session
|
|
146
|
+
self._pool = utils.ConnectionPool[aiohttp.ClientWebSocketResponse](
|
|
147
|
+
connect_cb=self._connect_ws,
|
|
148
|
+
close_cb=self._close_ws,
|
|
149
|
+
max_session_duration=300,
|
|
150
|
+
mark_refreshed_on_get=True,
|
|
151
|
+
)
|
|
152
|
+
self._streams = weakref.WeakSet[SynthesizeStream]()
|
|
153
|
+
self._sentence_tokenizer = (
|
|
154
|
+
tokenizer
|
|
155
|
+
if is_given(tokenizer)
|
|
156
|
+
else tokenize.blingfire.SentenceTokenizer(min_sentence_len=min_buffer_size)
|
|
157
|
+
)
|
|
158
|
+
self._stream_pacer: tts.SentenceStreamPacer | None = None
|
|
159
|
+
if text_pacing is True:
|
|
160
|
+
self._stream_pacer = tts.SentenceStreamPacer()
|
|
161
|
+
elif isinstance(text_pacing, tts.SentenceStreamPacer):
|
|
162
|
+
self._stream_pacer = text_pacing
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def model(self) -> str:
|
|
166
|
+
return self._opts.model
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def provider(self) -> str:
|
|
170
|
+
return "Murf"
|
|
171
|
+
|
|
172
|
+
async def _connect_ws(self, timeout: float) -> aiohttp.ClientWebSocketResponse:
|
|
173
|
+
session = self._ensure_session()
|
|
174
|
+
url = self._opts.get_ws_url(
|
|
175
|
+
f"/v1/speech/stream-input?api-key={self._opts.api_key}&sample_rate={self._opts.sample_rate}&format={self._opts.encoding}&model={self._opts.model}"
|
|
176
|
+
)
|
|
177
|
+
return await asyncio.wait_for(session.ws_connect(url), timeout)
|
|
178
|
+
|
|
179
|
+
async def _close_ws(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
180
|
+
await ws.close()
|
|
181
|
+
|
|
182
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
183
|
+
if not self._session:
|
|
184
|
+
self._session = utils.http_context.http_session()
|
|
185
|
+
|
|
186
|
+
return self._session
|
|
187
|
+
|
|
188
|
+
def prewarm(self) -> None:
|
|
189
|
+
self._pool.prewarm()
|
|
190
|
+
|
|
191
|
+
def update_options(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
locale: NotGivenOr[str] = NOT_GIVEN,
|
|
195
|
+
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
196
|
+
style: NotGivenOr[str | None] = NOT_GIVEN,
|
|
197
|
+
speed: NotGivenOr[int | None] = NOT_GIVEN,
|
|
198
|
+
pitch: NotGivenOr[int | None] = NOT_GIVEN,
|
|
199
|
+
) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Update the Text-to-Speech (TTS) configuration options.
|
|
202
|
+
|
|
203
|
+
This method allows updating the TTS settings, including model, locale, voice, style,
|
|
204
|
+
speed and pitch. If any parameter is not provided, the existing value will be retained.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
locale (str, optional): The locale for synthesis (e.g., "en-US", "en-UK").
|
|
208
|
+
voice (str, optional): The voice ID from Murf AI's voice library. (e.g. "en-US-matthew")
|
|
209
|
+
style (str | None, optional): The voice style to apply (e.g., "Conversation", "Promo").
|
|
210
|
+
speed (int | None, optional): Controls the speech speed. Positive values increase speed, negative values decrease it. Valid range: -50 to 50.
|
|
211
|
+
pitch (int | None, optional): Controls the speech pitch. Positive values raise pitch, negative values lower it. Valid range: -50 to 50.
|
|
212
|
+
"""
|
|
213
|
+
if is_given(locale):
|
|
214
|
+
self._opts.locale = locale
|
|
215
|
+
if is_given(voice):
|
|
216
|
+
self._opts.voice = voice
|
|
217
|
+
if is_given(style):
|
|
218
|
+
self._opts.style = style
|
|
219
|
+
if is_given(speed):
|
|
220
|
+
self._opts.speed = speed
|
|
221
|
+
if is_given(pitch):
|
|
222
|
+
self._opts.pitch = pitch
|
|
223
|
+
|
|
224
|
+
def synthesize(
|
|
225
|
+
self, text: str, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
226
|
+
) -> ChunkedStream:
|
|
227
|
+
return ChunkedStream(tts=self, input_text=text, conn_options=conn_options)
|
|
228
|
+
|
|
229
|
+
def stream(
|
|
230
|
+
self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS
|
|
231
|
+
) -> SynthesizeStream:
|
|
232
|
+
stream = SynthesizeStream(tts=self, conn_options=conn_options)
|
|
233
|
+
self._streams.add(stream)
|
|
234
|
+
return stream
|
|
235
|
+
|
|
236
|
+
async def aclose(self) -> None:
|
|
237
|
+
for stream in list(self._streams):
|
|
238
|
+
await stream.aclose()
|
|
239
|
+
|
|
240
|
+
self._streams.clear()
|
|
241
|
+
await self._pool.aclose()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class ChunkedStream(tts.ChunkedStream):
|
|
245
|
+
"""Synthesize chunked text using the http streaming output endpoint"""
|
|
246
|
+
|
|
247
|
+
def __init__(self, *, tts: TTS, input_text: str, conn_options: APIConnectOptions) -> None:
|
|
248
|
+
super().__init__(tts=tts, input_text=input_text, conn_options=conn_options)
|
|
249
|
+
self._tts: TTS = tts
|
|
250
|
+
self._opts = replace(tts._opts)
|
|
251
|
+
|
|
252
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
253
|
+
request_id = utils.shortuuid()
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
async with self._tts._ensure_session().post(
|
|
257
|
+
self._opts.get_http_url("/v1/speech/stream"),
|
|
258
|
+
headers={API_AUTH_HEADER: self._opts.api_key},
|
|
259
|
+
json={
|
|
260
|
+
"text": self._input_text,
|
|
261
|
+
"model": self._opts.model,
|
|
262
|
+
"multiNativeLocale": self._opts.locale,
|
|
263
|
+
"voice_id": self._opts.voice,
|
|
264
|
+
"style": self._opts.style,
|
|
265
|
+
"rate": self._opts.speed,
|
|
266
|
+
"pitch": self._opts.pitch,
|
|
267
|
+
"format": self._opts.encoding,
|
|
268
|
+
"sample_rate": self._opts.sample_rate,
|
|
269
|
+
},
|
|
270
|
+
timeout=aiohttp.ClientTimeout(total=30, sock_connect=self._conn_options.timeout),
|
|
271
|
+
) as resp:
|
|
272
|
+
resp.raise_for_status()
|
|
273
|
+
|
|
274
|
+
output_emitter.initialize(
|
|
275
|
+
request_id=request_id,
|
|
276
|
+
sample_rate=self._opts.sample_rate,
|
|
277
|
+
num_channels=1,
|
|
278
|
+
mime_type="audio/pcm",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async for data, _ in resp.content.iter_chunks():
|
|
282
|
+
output_emitter.push(data)
|
|
283
|
+
|
|
284
|
+
output_emitter.flush()
|
|
285
|
+
except asyncio.TimeoutError:
|
|
286
|
+
raise APITimeoutError() from None
|
|
287
|
+
except aiohttp.ClientResponseError as e:
|
|
288
|
+
raise APIStatusError(
|
|
289
|
+
message=e.message, status_code=e.status, request_id=None, body=None
|
|
290
|
+
) from None
|
|
291
|
+
except Exception as e:
|
|
292
|
+
raise APIConnectionError() from e
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class SynthesizeStream(tts.SynthesizeStream):
|
|
296
|
+
def __init__(self, *, tts: TTS, conn_options: APIConnectOptions):
|
|
297
|
+
super().__init__(tts=tts, conn_options=conn_options)
|
|
298
|
+
self._tts: TTS = tts
|
|
299
|
+
self._opts = replace(tts._opts)
|
|
300
|
+
|
|
301
|
+
async def _run(self, output_emitter: tts.AudioEmitter) -> None:
|
|
302
|
+
request_id = utils.shortuuid()
|
|
303
|
+
output_emitter.initialize(
|
|
304
|
+
request_id=request_id,
|
|
305
|
+
sample_rate=self._opts.sample_rate,
|
|
306
|
+
num_channels=1,
|
|
307
|
+
mime_type="audio/pcm",
|
|
308
|
+
stream=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
input_sent_event = asyncio.Event()
|
|
312
|
+
|
|
313
|
+
sent_tokenizer_stream = self._tts._sentence_tokenizer.stream()
|
|
314
|
+
if self._tts._stream_pacer:
|
|
315
|
+
sent_tokenizer_stream = self._tts._stream_pacer.wrap(
|
|
316
|
+
sent_stream=sent_tokenizer_stream,
|
|
317
|
+
audio_emitter=output_emitter,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
async def _sentence_stream_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
321
|
+
context_id = utils.shortuuid()
|
|
322
|
+
base_pkt = _to_murf_websocket_pkt(self._opts)
|
|
323
|
+
async for ev in sent_tokenizer_stream:
|
|
324
|
+
token_pkt = base_pkt.copy()
|
|
325
|
+
token_pkt["context_id"] = context_id
|
|
326
|
+
token_pkt["text"] = ev.token + " "
|
|
327
|
+
self._mark_started()
|
|
328
|
+
await ws.send_str(json.dumps(token_pkt))
|
|
329
|
+
input_sent_event.set()
|
|
330
|
+
|
|
331
|
+
end_pkt = base_pkt.copy()
|
|
332
|
+
end_pkt["context_id"] = context_id
|
|
333
|
+
end_pkt["end"] = True
|
|
334
|
+
await ws.send_str(json.dumps(end_pkt))
|
|
335
|
+
input_sent_event.set()
|
|
336
|
+
|
|
337
|
+
async def _input_task() -> None:
|
|
338
|
+
async for data in self._input_ch:
|
|
339
|
+
if isinstance(data, self._FlushSentinel):
|
|
340
|
+
sent_tokenizer_stream.flush()
|
|
341
|
+
continue
|
|
342
|
+
|
|
343
|
+
sent_tokenizer_stream.push_text(data)
|
|
344
|
+
|
|
345
|
+
sent_tokenizer_stream.end_input()
|
|
346
|
+
|
|
347
|
+
async def _recv_task(ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
348
|
+
current_segment_id: str | None = None
|
|
349
|
+
await input_sent_event.wait()
|
|
350
|
+
while True:
|
|
351
|
+
msg = await ws.receive()
|
|
352
|
+
if msg.type in (
|
|
353
|
+
aiohttp.WSMsgType.CLOSED,
|
|
354
|
+
aiohttp.WSMsgType.CLOSE,
|
|
355
|
+
aiohttp.WSMsgType.CLOSING,
|
|
356
|
+
):
|
|
357
|
+
raise APIStatusError(
|
|
358
|
+
"Murf AI connection closed unexpectedly", request_id=request_id
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
362
|
+
logger.warning("unexpected Murf AI message type %s", msg.type)
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
data = json.loads(msg.data)
|
|
366
|
+
segment_id = data.get("context_id")
|
|
367
|
+
if current_segment_id is None:
|
|
368
|
+
current_segment_id = segment_id
|
|
369
|
+
output_emitter.start_segment(segment_id=current_segment_id)
|
|
370
|
+
if data.get("audio"):
|
|
371
|
+
b64data = base64.b64decode(data["audio"])
|
|
372
|
+
output_emitter.push(b64data)
|
|
373
|
+
elif data.get("final"):
|
|
374
|
+
if sent_tokenizer_stream.closed:
|
|
375
|
+
# close only if the input stream is closed
|
|
376
|
+
output_emitter.end_input()
|
|
377
|
+
break
|
|
378
|
+
else:
|
|
379
|
+
logger.warning("unexpected message %s", data)
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
async with self._tts._pool.connection(timeout=self._conn_options.timeout) as ws:
|
|
383
|
+
tasks = [
|
|
384
|
+
asyncio.create_task(_input_task()),
|
|
385
|
+
asyncio.create_task(_sentence_stream_task(ws)),
|
|
386
|
+
asyncio.create_task(_recv_task(ws)),
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
await asyncio.gather(*tasks)
|
|
391
|
+
finally:
|
|
392
|
+
input_sent_event.set()
|
|
393
|
+
await sent_tokenizer_stream.aclose()
|
|
394
|
+
await utils.aio.gracefully_cancel(*tasks)
|
|
395
|
+
except asyncio.TimeoutError:
|
|
396
|
+
raise APITimeoutError() from None
|
|
397
|
+
except aiohttp.ClientResponseError as e:
|
|
398
|
+
raise APIStatusError(
|
|
399
|
+
message=e.message, status_code=e.status, request_id=None, body=None
|
|
400
|
+
) from None
|
|
401
|
+
except Exception as e:
|
|
402
|
+
raise APIConnectionError() from e
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _to_murf_websocket_pkt(opts: _TTSOptions) -> dict[str, Any]:
|
|
406
|
+
voice_config: dict[str, Any] = {}
|
|
407
|
+
|
|
408
|
+
if opts.voice:
|
|
409
|
+
voice_config["voice_id"] = opts.voice
|
|
410
|
+
|
|
411
|
+
if opts.style:
|
|
412
|
+
voice_config["style"] = opts.style
|
|
413
|
+
|
|
414
|
+
if opts.speed:
|
|
415
|
+
voice_config["rate"] = opts.speed
|
|
416
|
+
|
|
417
|
+
if opts.pitch:
|
|
418
|
+
voice_config["pitch"] = opts.pitch
|
|
419
|
+
|
|
420
|
+
if opts.locale:
|
|
421
|
+
voice_config["multi_native_locale"] = opts.locale
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
"voice_config": voice_config,
|
|
425
|
+
"min_buffer_size": opts.min_buffer_size,
|
|
426
|
+
"max_buffer_delay_in_ms": opts.max_buffer_delay_in_ms,
|
|
427
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2023 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
|
+
__version__ = "1.4.6"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livekit-plugins-murf"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "LiveKit Agents Plugin for Murf"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10.0"
|
|
12
|
+
authors = [{ name = "LiveKit", email = "hello@livekit.io" }]
|
|
13
|
+
keywords = ["webrtc", "realtime", "audio", "video", "livekit"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
18
|
+
"Topic :: Multimedia :: Video",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
]
|
|
25
|
+
dependencies = ["livekit-agents>=1.4.6"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Documentation = "https://docs.livekit.io"
|
|
29
|
+
Website = "https://livekit.io/"
|
|
30
|
+
Source = "https://github.com/livekit/agents"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "livekit/plugins/murf/version.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["livekit"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
include = ["/livekit"]
|