voxport 0.4.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.
- voxport-0.4.0/LICENSE +21 -0
- voxport-0.4.0/PKG-INFO +218 -0
- voxport-0.4.0/README.md +187 -0
- voxport-0.4.0/pyproject.toml +63 -0
- voxport-0.4.0/setup.cfg +4 -0
- voxport-0.4.0/tests/test_interfaces.py +46 -0
- voxport-0.4.0/tests/test_optional_extras.py +23 -0
- voxport-0.4.0/tests/test_wyoming_whisper.py +134 -0
- voxport-0.4.0/voxport/__init__.py +0 -0
- voxport-0.4.0/voxport/stt/__init__.py +0 -0
- voxport-0.4.0/voxport/stt/base.py +26 -0
- voxport-0.4.0/voxport/stt/wyoming_whisper.py +101 -0
- voxport-0.4.0/voxport/tts/__init__.py +0 -0
- voxport-0.4.0/voxport/tts/base.py +27 -0
- voxport-0.4.0/voxport/tts/kokoro_direct.py +128 -0
- voxport-0.4.0/voxport.egg-info/PKG-INFO +218 -0
- voxport-0.4.0/voxport.egg-info/SOURCES.txt +18 -0
- voxport-0.4.0/voxport.egg-info/dependency_links.txt +1 -0
- voxport-0.4.0/voxport.egg-info/requires.txt +11 -0
- voxport-0.4.0/voxport.egg-info/top_level.txt +1 -0
voxport-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brien Alkire
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
voxport-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voxport
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Text-to-speech and speech-to-text interfaces with swappable engine adapters (Kokoro, Wyoming Whisper).
|
|
5
|
+
Author: Brien Alkire
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/brienalkire/voxport
|
|
8
|
+
Project-URL: Repository, https://github.com/brienalkire/voxport
|
|
9
|
+
Project-URL: Changelog, https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/brienalkire/voxport/issues
|
|
11
|
+
Keywords: tts,stt,text-to-speech,speech-to-text,speech,voice,kokoro,whisper,wyoming
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=2.0
|
|
23
|
+
Provides-Extra: kokoro
|
|
24
|
+
Requires-Dist: kokoro>=0.9.4; extra == "kokoro"
|
|
25
|
+
Requires-Dist: torch>=2.12.0; extra == "kokoro"
|
|
26
|
+
Provides-Extra: wyoming
|
|
27
|
+
Requires-Dist: wyoming>=1.5.0; extra == "wyoming"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: voxport[kokoro,wyoming]; extra == "all"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# voxport
|
|
33
|
+
|
|
34
|
+
Small Python interfaces for text-to-speech and speech-to-text, with
|
|
35
|
+
swappable engine adapters. Your code calls one method,
|
|
36
|
+
`synthesize()` or `transcribe()`, and never touches an engine's API
|
|
37
|
+
directly, so switching engines means changing one line where the adapter
|
|
38
|
+
is created.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
TTSEngine.synthesize(text, voice) -> audio
|
|
42
|
+
STTEngine.transcribe(audio) -> text
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Included adapters:
|
|
46
|
+
|
|
47
|
+
- **`KokoroDirectTTS`**: runs the [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M)
|
|
48
|
+
TTS model in your own process. The model loads once and stays in
|
|
49
|
+
memory, with no server and no network hop.
|
|
50
|
+
- **`WyomingWhisperSTT`**: a client for a [Wyoming](https://github.com/OHF-Voice/wyoming)
|
|
51
|
+
protocol Whisper server, the same kind of server Home Assistant's voice
|
|
52
|
+
pipeline uses. It loads no model itself; transcription happens on the
|
|
53
|
+
server.
|
|
54
|
+
|
|
55
|
+
voxport is a library only: it has no command-line tool or app of its
|
|
56
|
+
own. It is pre-1.0, so the API may still change between minor versions
|
|
57
|
+
(see the [changelog](https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md)).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Requires Python 3.10+. Each adapter's dependencies are an optional
|
|
62
|
+
extra, so you install only what you use:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pip install "voxport[kokoro]" # KokoroDirectTTS
|
|
66
|
+
pip install "voxport[wyoming]" # WyomingWhisperSTT
|
|
67
|
+
pip install "voxport[all]" # both
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Extra | Adds | For |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `kokoro` | Kokoro, torch (about 2 GB) | `KokoroDirectTTS` |
|
|
73
|
+
| `wyoming` | wyoming | `WyomingWhisperSTT` |
|
|
74
|
+
| `all` | both of the above | everything |
|
|
75
|
+
|
|
76
|
+
A plain `pip install voxport` includes only the interfaces and numpy.
|
|
77
|
+
Importing an adapter without its extra raises an `ImportError` that
|
|
78
|
+
names the extra to install.
|
|
79
|
+
|
|
80
|
+
Things voxport doesn't install for you:
|
|
81
|
+
|
|
82
|
+
- `WyomingWhisperSTT` needs a running Wyoming Whisper server. See
|
|
83
|
+
[Running a Wyoming Whisper server](#running-a-wyoming-whisper-server).
|
|
84
|
+
- On first use, Kokoro downloads its model weights and voice packs from
|
|
85
|
+
Hugging Face, and spaCy's `en_core_web_sm` English model. See
|
|
86
|
+
[Offline use](#offline-use) for machines that can't reach those.
|
|
87
|
+
- `soundfile`, used in the examples below to write WAV files.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
### Text to speech
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import soundfile as sf
|
|
95
|
+
from voxport.tts.kokoro_direct import KokoroDirectTTS
|
|
96
|
+
|
|
97
|
+
tts = KokoroDirectTTS() # reuse one instance; models load once per accent
|
|
98
|
+
audio = tts.synthesize("Hello from voxport.", voice="af_heart", speed=1.0)
|
|
99
|
+
sf.write("hello.wav", audio, tts.sample_rate) # 24 kHz mono
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`synthesize()` returns a 1-D numpy array. The first letter of the voice
|
|
103
|
+
name picks the accent (`a` American, `b` British, ...), following
|
|
104
|
+
Kokoro's voice naming; pass `lang_code=` to override it. Kokoro splits
|
|
105
|
+
long text into segments itself, so there is no length limit.
|
|
106
|
+
|
|
107
|
+
### Speech to text
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from voxport.stt.wyoming_whisper import WyomingWhisperSTT
|
|
111
|
+
|
|
112
|
+
stt = WyomingWhisperSTT(host="localhost", port=10300) # the defaults
|
|
113
|
+
text = stt.transcribe(audio_16k, language="en")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`audio_16k` is a 1-D numpy array of mono audio at `stt.sample_rate`
|
|
117
|
+
(16 kHz), either int16 samples or floats in [-1, 1]. Resample first if
|
|
118
|
+
your audio has a different rate; Kokoro's output, for example, is
|
|
119
|
+
24 kHz. Each call opens a new connection to the server.
|
|
120
|
+
|
|
121
|
+
### From async code
|
|
122
|
+
|
|
123
|
+
Both calls block, and `transcribe()` runs its own asyncio event loop, so
|
|
124
|
+
from code that already has a running loop, call them in a worker thread:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
audio = await asyncio.to_thread(tts.synthesize, text, "bm_george")
|
|
128
|
+
text = await asyncio.to_thread(stt.transcribe, audio_16k, "en")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Writing your own adapter
|
|
132
|
+
|
|
133
|
+
An adapter subclasses `TTSEngine` or `STTEngine`, sets `sample_rate`,
|
|
134
|
+
and implements the one abstract method:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
import numpy as np
|
|
138
|
+
from voxport.tts.base import TTSEngine
|
|
139
|
+
|
|
140
|
+
class SilenceTTS(TTSEngine):
|
|
141
|
+
"""A toy engine: one second of silence, whatever the text."""
|
|
142
|
+
|
|
143
|
+
sample_rate = 16000
|
|
144
|
+
|
|
145
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0):
|
|
146
|
+
return np.zeros(self.sample_rate, dtype=np.float32)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Code written against `TTSEngine` works with `SilenceTTS` and
|
|
150
|
+
`KokoroDirectTTS` unchanged. Adapters may accept extra, engine-specific
|
|
151
|
+
keyword arguments (like `KokoroDirectTTS`'s `lang_code`), as long as the
|
|
152
|
+
base signature still works.
|
|
153
|
+
|
|
154
|
+
## Offline use
|
|
155
|
+
|
|
156
|
+
By default, Kokoro's weights and voice packs download to Hugging Face's
|
|
157
|
+
standard cache (`~/.cache/huggingface`, or wherever `HF_HOME` points).
|
|
158
|
+
To run on a machine that can't reach huggingface.co:
|
|
159
|
+
|
|
160
|
+
1. On a machine with internet access, synthesize once with each voice
|
|
161
|
+
you plan to use, so the model and those voice packs are cached.
|
|
162
|
+
2. Copy that cache to the offline machine, keeping Hugging Face's
|
|
163
|
+
`hub/models--<org>--<name>/...` layout, and point `HF_HOME` at it.
|
|
164
|
+
3. Set `HF_HUB_OFFLINE=1` on the offline machine. voxport never sets
|
|
165
|
+
this for you. With it set, only voices already in the cache work; an
|
|
166
|
+
uncached voice fails with a Hub connection error instead of
|
|
167
|
+
downloading.
|
|
168
|
+
|
|
169
|
+
Kokoro's English phonemizer also needs spaCy's `en_core_web_sm` model,
|
|
170
|
+
which downloads from GitHub, not Hugging Face, so `HF_HUB_OFFLINE`
|
|
171
|
+
doesn't cover it. It downloads once per Python environment, so install
|
|
172
|
+
it while online:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
python -m spacy download en_core_web_sm
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Repo-local cache (source checkouts only).** If you run voxport from a
|
|
179
|
+
clone, you can keep the cache inside it instead: create an `hf_cache/`
|
|
180
|
+
folder at the repo root (it's gitignored) and `KokoroDirectTTS` uses it
|
|
181
|
+
as `HF_HOME`, unless you've set `HF_HOME` yourself. That makes the
|
|
182
|
+
cache easy to copy along with the checkout.
|
|
183
|
+
|
|
184
|
+
## Running a Wyoming Whisper server
|
|
185
|
+
|
|
186
|
+
`WyomingWhisperSTT` needs a server to talk to. For local testing, the
|
|
187
|
+
easiest option is the [`rhasspy/wyoming-whisper`](https://github.com/rhasspy/wyoming-faster-whisper)
|
|
188
|
+
Docker image:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
docker run -d --name whisper --restart unless-stopped -p 10300:10300 -v wyoming-data:/data rhasspy/wyoming-whisper --model tiny-int8 --language en
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The image is named `rhasspy/wyoming-whisper`, even though its GitHub
|
|
195
|
+
repo is `wyoming-faster-whisper`. `docker logs whisper` shows `Ready`
|
|
196
|
+
once the model has loaded. `tiny-int8` is small and fast but not very
|
|
197
|
+
accurate; use a larger model when transcription quality matters.
|
|
198
|
+
|
|
199
|
+
If you already run Home Assistant with its Whisper add-on, you can point
|
|
200
|
+
`WyomingWhisperSTT` at that server's host and port instead.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
git clone https://github.com/brienalkire/voxport.git
|
|
206
|
+
cd voxport
|
|
207
|
+
pip install -e ".[all]" pytest
|
|
208
|
+
python -m pytest
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The tests need no network, no Whisper server and no Kokoro model:
|
|
212
|
+
`WyomingWhisperSTT` is tested against a fake in-process Wyoming server.
|
|
213
|
+
Installing `.[wyoming]` instead of `.[all]` is enough to run them and
|
|
214
|
+
skips the 2 GB torch download.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
[MIT](https://github.com/brienalkire/voxport/blob/main/LICENSE)
|
voxport-0.4.0/README.md
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# voxport
|
|
2
|
+
|
|
3
|
+
Small Python interfaces for text-to-speech and speech-to-text, with
|
|
4
|
+
swappable engine adapters. Your code calls one method,
|
|
5
|
+
`synthesize()` or `transcribe()`, and never touches an engine's API
|
|
6
|
+
directly, so switching engines means changing one line where the adapter
|
|
7
|
+
is created.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
TTSEngine.synthesize(text, voice) -> audio
|
|
11
|
+
STTEngine.transcribe(audio) -> text
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Included adapters:
|
|
15
|
+
|
|
16
|
+
- **`KokoroDirectTTS`**: runs the [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M)
|
|
17
|
+
TTS model in your own process. The model loads once and stays in
|
|
18
|
+
memory, with no server and no network hop.
|
|
19
|
+
- **`WyomingWhisperSTT`**: a client for a [Wyoming](https://github.com/OHF-Voice/wyoming)
|
|
20
|
+
protocol Whisper server, the same kind of server Home Assistant's voice
|
|
21
|
+
pipeline uses. It loads no model itself; transcription happens on the
|
|
22
|
+
server.
|
|
23
|
+
|
|
24
|
+
voxport is a library only: it has no command-line tool or app of its
|
|
25
|
+
own. It is pre-1.0, so the API may still change between minor versions
|
|
26
|
+
(see the [changelog](https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md)).
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
Requires Python 3.10+. Each adapter's dependencies are an optional
|
|
31
|
+
extra, so you install only what you use:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install "voxport[kokoro]" # KokoroDirectTTS
|
|
35
|
+
pip install "voxport[wyoming]" # WyomingWhisperSTT
|
|
36
|
+
pip install "voxport[all]" # both
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Extra | Adds | For |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `kokoro` | Kokoro, torch (about 2 GB) | `KokoroDirectTTS` |
|
|
42
|
+
| `wyoming` | wyoming | `WyomingWhisperSTT` |
|
|
43
|
+
| `all` | both of the above | everything |
|
|
44
|
+
|
|
45
|
+
A plain `pip install voxport` includes only the interfaces and numpy.
|
|
46
|
+
Importing an adapter without its extra raises an `ImportError` that
|
|
47
|
+
names the extra to install.
|
|
48
|
+
|
|
49
|
+
Things voxport doesn't install for you:
|
|
50
|
+
|
|
51
|
+
- `WyomingWhisperSTT` needs a running Wyoming Whisper server. See
|
|
52
|
+
[Running a Wyoming Whisper server](#running-a-wyoming-whisper-server).
|
|
53
|
+
- On first use, Kokoro downloads its model weights and voice packs from
|
|
54
|
+
Hugging Face, and spaCy's `en_core_web_sm` English model. See
|
|
55
|
+
[Offline use](#offline-use) for machines that can't reach those.
|
|
56
|
+
- `soundfile`, used in the examples below to write WAV files.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
### Text to speech
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import soundfile as sf
|
|
64
|
+
from voxport.tts.kokoro_direct import KokoroDirectTTS
|
|
65
|
+
|
|
66
|
+
tts = KokoroDirectTTS() # reuse one instance; models load once per accent
|
|
67
|
+
audio = tts.synthesize("Hello from voxport.", voice="af_heart", speed=1.0)
|
|
68
|
+
sf.write("hello.wav", audio, tts.sample_rate) # 24 kHz mono
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`synthesize()` returns a 1-D numpy array. The first letter of the voice
|
|
72
|
+
name picks the accent (`a` American, `b` British, ...), following
|
|
73
|
+
Kokoro's voice naming; pass `lang_code=` to override it. Kokoro splits
|
|
74
|
+
long text into segments itself, so there is no length limit.
|
|
75
|
+
|
|
76
|
+
### Speech to text
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from voxport.stt.wyoming_whisper import WyomingWhisperSTT
|
|
80
|
+
|
|
81
|
+
stt = WyomingWhisperSTT(host="localhost", port=10300) # the defaults
|
|
82
|
+
text = stt.transcribe(audio_16k, language="en")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`audio_16k` is a 1-D numpy array of mono audio at `stt.sample_rate`
|
|
86
|
+
(16 kHz), either int16 samples or floats in [-1, 1]. Resample first if
|
|
87
|
+
your audio has a different rate; Kokoro's output, for example, is
|
|
88
|
+
24 kHz. Each call opens a new connection to the server.
|
|
89
|
+
|
|
90
|
+
### From async code
|
|
91
|
+
|
|
92
|
+
Both calls block, and `transcribe()` runs its own asyncio event loop, so
|
|
93
|
+
from code that already has a running loop, call them in a worker thread:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
audio = await asyncio.to_thread(tts.synthesize, text, "bm_george")
|
|
97
|
+
text = await asyncio.to_thread(stt.transcribe, audio_16k, "en")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Writing your own adapter
|
|
101
|
+
|
|
102
|
+
An adapter subclasses `TTSEngine` or `STTEngine`, sets `sample_rate`,
|
|
103
|
+
and implements the one abstract method:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import numpy as np
|
|
107
|
+
from voxport.tts.base import TTSEngine
|
|
108
|
+
|
|
109
|
+
class SilenceTTS(TTSEngine):
|
|
110
|
+
"""A toy engine: one second of silence, whatever the text."""
|
|
111
|
+
|
|
112
|
+
sample_rate = 16000
|
|
113
|
+
|
|
114
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0):
|
|
115
|
+
return np.zeros(self.sample_rate, dtype=np.float32)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Code written against `TTSEngine` works with `SilenceTTS` and
|
|
119
|
+
`KokoroDirectTTS` unchanged. Adapters may accept extra, engine-specific
|
|
120
|
+
keyword arguments (like `KokoroDirectTTS`'s `lang_code`), as long as the
|
|
121
|
+
base signature still works.
|
|
122
|
+
|
|
123
|
+
## Offline use
|
|
124
|
+
|
|
125
|
+
By default, Kokoro's weights and voice packs download to Hugging Face's
|
|
126
|
+
standard cache (`~/.cache/huggingface`, or wherever `HF_HOME` points).
|
|
127
|
+
To run on a machine that can't reach huggingface.co:
|
|
128
|
+
|
|
129
|
+
1. On a machine with internet access, synthesize once with each voice
|
|
130
|
+
you plan to use, so the model and those voice packs are cached.
|
|
131
|
+
2. Copy that cache to the offline machine, keeping Hugging Face's
|
|
132
|
+
`hub/models--<org>--<name>/...` layout, and point `HF_HOME` at it.
|
|
133
|
+
3. Set `HF_HUB_OFFLINE=1` on the offline machine. voxport never sets
|
|
134
|
+
this for you. With it set, only voices already in the cache work; an
|
|
135
|
+
uncached voice fails with a Hub connection error instead of
|
|
136
|
+
downloading.
|
|
137
|
+
|
|
138
|
+
Kokoro's English phonemizer also needs spaCy's `en_core_web_sm` model,
|
|
139
|
+
which downloads from GitHub, not Hugging Face, so `HF_HUB_OFFLINE`
|
|
140
|
+
doesn't cover it. It downloads once per Python environment, so install
|
|
141
|
+
it while online:
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
python -m spacy download en_core_web_sm
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Repo-local cache (source checkouts only).** If you run voxport from a
|
|
148
|
+
clone, you can keep the cache inside it instead: create an `hf_cache/`
|
|
149
|
+
folder at the repo root (it's gitignored) and `KokoroDirectTTS` uses it
|
|
150
|
+
as `HF_HOME`, unless you've set `HF_HOME` yourself. That makes the
|
|
151
|
+
cache easy to copy along with the checkout.
|
|
152
|
+
|
|
153
|
+
## Running a Wyoming Whisper server
|
|
154
|
+
|
|
155
|
+
`WyomingWhisperSTT` needs a server to talk to. For local testing, the
|
|
156
|
+
easiest option is the [`rhasspy/wyoming-whisper`](https://github.com/rhasspy/wyoming-faster-whisper)
|
|
157
|
+
Docker image:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
docker run -d --name whisper --restart unless-stopped -p 10300:10300 -v wyoming-data:/data rhasspy/wyoming-whisper --model tiny-int8 --language en
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The image is named `rhasspy/wyoming-whisper`, even though its GitHub
|
|
164
|
+
repo is `wyoming-faster-whisper`. `docker logs whisper` shows `Ready`
|
|
165
|
+
once the model has loaded. `tiny-int8` is small and fast but not very
|
|
166
|
+
accurate; use a larger model when transcription quality matters.
|
|
167
|
+
|
|
168
|
+
If you already run Home Assistant with its Whisper add-on, you can point
|
|
169
|
+
`WyomingWhisperSTT` at that server's host and port instead.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
git clone https://github.com/brienalkire/voxport.git
|
|
175
|
+
cd voxport
|
|
176
|
+
pip install -e ".[all]" pytest
|
|
177
|
+
python -m pytest
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The tests need no network, no Whisper server and no Kokoro model:
|
|
181
|
+
`WyomingWhisperSTT` is tested against a fake in-process Wyoming server.
|
|
182
|
+
Installing `.[wyoming]` instead of `.[all]` is enough to run them and
|
|
183
|
+
skips the 2 GB torch download.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
[MIT](https://github.com/brienalkire/voxport/blob/main/LICENSE)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# 77+ for the PEP 639 `license = "MIT"` expression below.
|
|
3
|
+
requires = ["setuptools>=77"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "voxport"
|
|
8
|
+
version = "0.4.0"
|
|
9
|
+
description = "Text-to-speech and speech-to-text interfaces with swappable engine adapters (Kokoro, Wyoming Whisper)."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Brien Alkire" }]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = [
|
|
16
|
+
"tts",
|
|
17
|
+
"stt",
|
|
18
|
+
"text-to-speech",
|
|
19
|
+
"speech-to-text",
|
|
20
|
+
"speech",
|
|
21
|
+
"voice",
|
|
22
|
+
"kokoro",
|
|
23
|
+
"whisper",
|
|
24
|
+
"wyoming",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"Development Status :: 3 - Alpha",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"Operating System :: OS Independent",
|
|
30
|
+
"Programming Language :: Python :: 3",
|
|
31
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
32
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"numpy>=2.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/brienalkire/voxport"
|
|
41
|
+
Repository = "https://github.com/brienalkire/voxport"
|
|
42
|
+
Changelog = "https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md"
|
|
43
|
+
Issues = "https://github.com/brienalkire/voxport/issues"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
# KokoroDirectTTS: in-process Kokoro model (pulls in torch, ~2 GB).
|
|
47
|
+
kokoro = [
|
|
48
|
+
"kokoro>=0.9.4",
|
|
49
|
+
"torch>=2.12.0",
|
|
50
|
+
]
|
|
51
|
+
# WyomingWhisperSTT: lightweight network client, no local model.
|
|
52
|
+
wyoming = [
|
|
53
|
+
"wyoming>=1.5.0",
|
|
54
|
+
]
|
|
55
|
+
all = [
|
|
56
|
+
"voxport[kokoro,wyoming]",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.setuptools]
|
|
63
|
+
packages = ["voxport", "voxport.tts", "voxport.stt"]
|
voxport-0.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""The TTSEngine and STTEngine ports: abstract, and usable via subclassing."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from voxport.stt.base import STTEngine
|
|
7
|
+
from voxport.tts.base import TTSEngine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_tts_engine_is_abstract():
|
|
11
|
+
with pytest.raises(TypeError):
|
|
12
|
+
TTSEngine()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_stt_engine_is_abstract():
|
|
16
|
+
with pytest.raises(TypeError):
|
|
17
|
+
STTEngine()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_tts_subclass_works():
|
|
21
|
+
class SilenceTTS(TTSEngine):
|
|
22
|
+
sample_rate = 16000
|
|
23
|
+
|
|
24
|
+
def synthesize(self, text, voice, speed=1.0):
|
|
25
|
+
return np.zeros(self.sample_rate, dtype=np.float32)
|
|
26
|
+
|
|
27
|
+
audio = SilenceTTS().synthesize("hello", "any-voice")
|
|
28
|
+
assert audio.shape == (16000,)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_stt_subclass_works():
|
|
32
|
+
class EchoSTT(STTEngine):
|
|
33
|
+
sample_rate = 16000
|
|
34
|
+
|
|
35
|
+
def transcribe(self, audio):
|
|
36
|
+
return f"{len(audio)} samples"
|
|
37
|
+
|
|
38
|
+
assert EchoSTT().transcribe(np.zeros(5)) == "5 samples"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_subclass_missing_method_is_rejected():
|
|
42
|
+
class Incomplete(TTSEngine):
|
|
43
|
+
sample_rate = 16000
|
|
44
|
+
|
|
45
|
+
with pytest.raises(TypeError):
|
|
46
|
+
Incomplete()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Importing an adapter without its optional extra names the extra to install."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.skipif(
|
|
9
|
+
importlib.util.find_spec("torch") is not None,
|
|
10
|
+
reason="torch is installed, so the kokoro extra is present",
|
|
11
|
+
)
|
|
12
|
+
def test_kokoro_adapter_without_extra_names_the_extra():
|
|
13
|
+
with pytest.raises(ImportError, match=r"voxport\[kokoro\]"):
|
|
14
|
+
import voxport.tts.kokoro_direct # noqa: F401
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.skipif(
|
|
18
|
+
importlib.util.find_spec("wyoming") is not None,
|
|
19
|
+
reason="wyoming is installed, so the wyoming extra is present",
|
|
20
|
+
)
|
|
21
|
+
def test_wyoming_adapter_without_extra_names_the_extra():
|
|
22
|
+
with pytest.raises(ImportError, match=r"voxport\[wyoming\]"):
|
|
23
|
+
import voxport.stt.wyoming_whisper # noqa: F401
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""WyomingWhisperSTT against a fake in-process Wyoming server.
|
|
2
|
+
|
|
3
|
+
transcribe() runs its own event loop via asyncio.run(), so the fake server
|
|
4
|
+
runs on a separate event loop in a background thread.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
pytest.importorskip("wyoming")
|
|
14
|
+
|
|
15
|
+
from wyoming.asr import Transcribe, Transcript # noqa: E402
|
|
16
|
+
from wyoming.audio import AudioChunk, AudioStart, AudioStop # noqa: E402
|
|
17
|
+
from wyoming.event import async_read_event, async_write_event # noqa: E402
|
|
18
|
+
|
|
19
|
+
from voxport.stt.wyoming_whisper import ( # noqa: E402
|
|
20
|
+
CHUNK_SAMPLES,
|
|
21
|
+
SAMPLE_RATE,
|
|
22
|
+
WyomingWhisperSTT,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeWyomingServer:
|
|
27
|
+
"""Records the events a client sends; replies with a transcript, or
|
|
28
|
+
closes the connection without one if `reply` is None."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, reply="hello world"):
|
|
31
|
+
self.reply = reply
|
|
32
|
+
self.events = []
|
|
33
|
+
self.port = None
|
|
34
|
+
self._loop = asyncio.new_event_loop()
|
|
35
|
+
self._ready = threading.Event()
|
|
36
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
37
|
+
|
|
38
|
+
def _run(self):
|
|
39
|
+
asyncio.set_event_loop(self._loop)
|
|
40
|
+
server = self._loop.run_until_complete(
|
|
41
|
+
asyncio.start_server(self._handle, "127.0.0.1", 0)
|
|
42
|
+
)
|
|
43
|
+
self.port = server.sockets[0].getsockname()[1]
|
|
44
|
+
self._ready.set()
|
|
45
|
+
self._loop.run_forever()
|
|
46
|
+
server.close()
|
|
47
|
+
self._loop.run_until_complete(server.wait_closed())
|
|
48
|
+
self._loop.close()
|
|
49
|
+
|
|
50
|
+
async def _handle(self, reader, writer):
|
|
51
|
+
while True:
|
|
52
|
+
event = await async_read_event(reader)
|
|
53
|
+
if event is None:
|
|
54
|
+
break
|
|
55
|
+
self.events.append(event)
|
|
56
|
+
if AudioStop.is_type(event.type):
|
|
57
|
+
if self.reply is not None:
|
|
58
|
+
await async_write_event(Transcript(text=self.reply).event(), writer)
|
|
59
|
+
break
|
|
60
|
+
writer.close()
|
|
61
|
+
|
|
62
|
+
def __enter__(self):
|
|
63
|
+
self._thread.start()
|
|
64
|
+
self._ready.wait(timeout=5)
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, *exc):
|
|
68
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
69
|
+
self._thread.join(timeout=5)
|
|
70
|
+
|
|
71
|
+
def of_type(self, cls):
|
|
72
|
+
return [cls.from_event(e) for e in self.events if cls.is_type(e.type)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def received_audio(server):
|
|
76
|
+
return np.frombuffer(
|
|
77
|
+
b"".join(chunk.audio for chunk in server.of_type(AudioChunk)), dtype=np.int16
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_returns_transcript_text():
|
|
82
|
+
with FakeWyomingServer(reply="hello world") as server:
|
|
83
|
+
stt = WyomingWhisperSTT(host="127.0.0.1", port=server.port)
|
|
84
|
+
assert stt.transcribe(np.zeros(1600, dtype=np.int16)) == "hello world"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_sends_events_in_protocol_order():
|
|
88
|
+
with FakeWyomingServer() as server:
|
|
89
|
+
WyomingWhisperSTT(host="127.0.0.1", port=server.port).transcribe(
|
|
90
|
+
np.zeros(1600, dtype=np.int16)
|
|
91
|
+
)
|
|
92
|
+
types = [e.type for e in server.events]
|
|
93
|
+
assert Transcribe.is_type(types[0])
|
|
94
|
+
assert AudioStart.is_type(types[1])
|
|
95
|
+
assert AudioStop.is_type(types[-1])
|
|
96
|
+
assert len(types) > 3
|
|
97
|
+
assert all(AudioChunk.is_type(t) for t in types[2:-1])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_sends_language_and_audio_format():
|
|
101
|
+
with FakeWyomingServer() as server:
|
|
102
|
+
WyomingWhisperSTT(host="127.0.0.1", port=server.port).transcribe(
|
|
103
|
+
np.zeros(1600, dtype=np.int16), language="en"
|
|
104
|
+
)
|
|
105
|
+
assert server.of_type(Transcribe)[0].language == "en"
|
|
106
|
+
start = server.of_type(AudioStart)[0]
|
|
107
|
+
assert (start.rate, start.width, start.channels) == (SAMPLE_RATE, 2, 1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_int16_audio_is_sent_unchanged_in_chunks():
|
|
111
|
+
audio = np.arange(-2500, 2500, dtype=np.int16) # 5000 samples
|
|
112
|
+
with FakeWyomingServer() as server:
|
|
113
|
+
WyomingWhisperSTT(host="127.0.0.1", port=server.port).transcribe(audio)
|
|
114
|
+
chunks = server.of_type(AudioChunk)
|
|
115
|
+
assert len(chunks) == -(-len(audio) // CHUNK_SAMPLES) # ceiling division
|
|
116
|
+
assert all(len(c.audio) <= CHUNK_SAMPLES * 2 for c in chunks)
|
|
117
|
+
np.testing.assert_array_equal(received_audio(server), audio)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_float_audio_is_clipped_and_converted_to_int16():
|
|
121
|
+
audio = np.array([0.0, 0.5, -0.5, 1.0, -1.0, 2.0, -2.0], dtype=np.float32)
|
|
122
|
+
with FakeWyomingServer() as server:
|
|
123
|
+
WyomingWhisperSTT(host="127.0.0.1", port=server.port).transcribe(audio)
|
|
124
|
+
np.testing.assert_array_equal(
|
|
125
|
+
received_audio(server),
|
|
126
|
+
np.array([0, 16383, -16383, 32767, -32767, 32767, -32767], dtype=np.int16),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_server_closing_without_transcript_raises():
|
|
131
|
+
with FakeWyomingServer(reply=None) as server:
|
|
132
|
+
stt = WyomingWhisperSTT(host="127.0.0.1", port=server.port)
|
|
133
|
+
with pytest.raises(ConnectionError):
|
|
134
|
+
stt.transcribe(np.zeros(1600, dtype=np.int16))
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""STTEngine port: the interface every speech-to-text adapter implements.
|
|
2
|
+
|
|
3
|
+
Callers depend only on this interface, not on any particular engine's
|
|
4
|
+
internals, so swapping the underlying engine later means writing one new
|
|
5
|
+
adapter, not touching any call site.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class STTEngine(ABC):
|
|
12
|
+
"""Transcribes speech to text."""
|
|
13
|
+
|
|
14
|
+
#: Sample rate, in Hz, `transcribe()` expects the input audio at.
|
|
15
|
+
sample_rate: int
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def transcribe(self, audio) -> str:
|
|
19
|
+
"""Return the transcribed text for `audio`.
|
|
20
|
+
|
|
21
|
+
`audio` is a 1-D numpy array of audio samples at `self.sample_rate`.
|
|
22
|
+
A concrete adapter may accept additional, engine-specific keyword
|
|
23
|
+
arguments beyond this minimal signature — callers that don't need
|
|
24
|
+
them can ignore this possibility entirely.
|
|
25
|
+
"""
|
|
26
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""WyomingWhisperSTT: network client to a running Wyoming Whisper server.
|
|
2
|
+
|
|
3
|
+
Unlike `KokoroDirectTTS` (in-process, model resident in the calling
|
|
4
|
+
process), this is a network client by design — no model loading, no local
|
|
5
|
+
Whisper install, all transcription work happens on the server. That
|
|
6
|
+
matches the actual use case: Home Assistant's own voice pipeline already
|
|
7
|
+
runs a Wyoming Whisper server, so this adapter talks to the same kind of
|
|
8
|
+
server rather than duplicating it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from wyoming.asr import Transcribe, Transcript
|
|
15
|
+
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
|
16
|
+
from wyoming.client import AsyncTcpClient
|
|
17
|
+
except ImportError as exc:
|
|
18
|
+
# wyoming is an optional dependency (the `wyoming` extra).
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"WyomingWhisperSTT needs the optional Wyoming dependency: "
|
|
21
|
+
"pip install 'voxport[wyoming]'"
|
|
22
|
+
) from exc
|
|
23
|
+
|
|
24
|
+
from voxport.stt.base import STTEngine
|
|
25
|
+
|
|
26
|
+
# Wyoming Whisper servers expect 16-bit mono PCM at 16 kHz.
|
|
27
|
+
SAMPLE_RATE = 16000
|
|
28
|
+
SAMPLE_WIDTH = 2 # bytes per sample (16-bit)
|
|
29
|
+
CHANNELS = 1
|
|
30
|
+
|
|
31
|
+
# Audio is streamed to the server in chunks rather than one giant payload.
|
|
32
|
+
CHUNK_SAMPLES = 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WyomingWhisperSTT(STTEngine):
|
|
36
|
+
"""Speech-to-text via a Wyoming-protocol Whisper server (e.g.
|
|
37
|
+
`rhasspy/wyoming-whisper`) — the same kind of server Home Assistant's
|
|
38
|
+
own voice pipeline talks to.
|
|
39
|
+
|
|
40
|
+
Connects fresh for each `transcribe()` call; the server keeps its
|
|
41
|
+
model resident, not this client. Host/port are configurable (not
|
|
42
|
+
hardcoded) since this will eventually point at different hardware —
|
|
43
|
+
a dedicated always-on machine, rather than localhost — once that
|
|
44
|
+
exists.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
sample_rate = SAMPLE_RATE
|
|
48
|
+
|
|
49
|
+
def __init__(self, host: str = "localhost", port: int = 10300):
|
|
50
|
+
self.host = host
|
|
51
|
+
self.port = port
|
|
52
|
+
|
|
53
|
+
def transcribe(self, audio, language: str | None = None) -> str:
|
|
54
|
+
"""Return the transcribed text for `audio`.
|
|
55
|
+
|
|
56
|
+
`audio` is a 1-D numpy array at `self.sample_rate` — int16 samples,
|
|
57
|
+
or float samples in [-1, 1] (auto-converted to int16).
|
|
58
|
+
|
|
59
|
+
This runs its own asyncio event loop internally (the Wyoming
|
|
60
|
+
client library is async-only) — call it from a worker thread
|
|
61
|
+
(`asyncio.to_thread`) when calling from code that already has an
|
|
62
|
+
event loop running, the same way `KokoroDirectTTS.synthesize()` is
|
|
63
|
+
called from loremaster's bot.
|
|
64
|
+
"""
|
|
65
|
+
return asyncio.run(self._transcribe_async(audio, language))
|
|
66
|
+
|
|
67
|
+
async def _transcribe_async(self, audio, language):
|
|
68
|
+
import numpy as np
|
|
69
|
+
|
|
70
|
+
audio = np.asarray(audio)
|
|
71
|
+
if np.issubdtype(audio.dtype, np.floating):
|
|
72
|
+
audio = np.clip(audio, -1.0, 1.0)
|
|
73
|
+
audio = (audio * 32767).astype(np.int16)
|
|
74
|
+
elif audio.dtype != np.int16:
|
|
75
|
+
audio = audio.astype(np.int16)
|
|
76
|
+
audio_bytes = audio.tobytes()
|
|
77
|
+
|
|
78
|
+
async with AsyncTcpClient(self.host, self.port) as client:
|
|
79
|
+
await client.write_event(Transcribe(language=language).event())
|
|
80
|
+
await client.write_event(
|
|
81
|
+
AudioStart(rate=self.sample_rate, width=SAMPLE_WIDTH, channels=CHANNELS).event()
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
chunk_bytes = CHUNK_SAMPLES * SAMPLE_WIDTH
|
|
85
|
+
for offset in range(0, len(audio_bytes), chunk_bytes):
|
|
86
|
+
chunk = audio_bytes[offset:offset + chunk_bytes]
|
|
87
|
+
await client.write_event(
|
|
88
|
+
AudioChunk(rate=self.sample_rate, width=SAMPLE_WIDTH,
|
|
89
|
+
channels=CHANNELS, audio=chunk).event()
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await client.write_event(AudioStop().event())
|
|
93
|
+
|
|
94
|
+
while True:
|
|
95
|
+
event = await client.read_event()
|
|
96
|
+
if event is None:
|
|
97
|
+
raise ConnectionError(
|
|
98
|
+
"Wyoming server closed the connection without sending a transcript."
|
|
99
|
+
)
|
|
100
|
+
if Transcript.is_type(event.type):
|
|
101
|
+
return Transcript.from_event(event).text
|
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""TTSEngine port: the interface every text-to-speech adapter implements.
|
|
2
|
+
|
|
3
|
+
Callers depend only on this interface, not on any particular engine's
|
|
4
|
+
internals, so swapping the underlying engine later means writing one new
|
|
5
|
+
adapter, not touching any call site.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TTSEngine(ABC):
|
|
12
|
+
"""Synthesizes speech from text."""
|
|
13
|
+
|
|
14
|
+
#: Sample rate, in Hz, of the audio returned by `synthesize()`.
|
|
15
|
+
sample_rate: int
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0):
|
|
19
|
+
"""Return synthesized audio for `text` spoken in `voice`.
|
|
20
|
+
|
|
21
|
+
Returns a 1-D numpy array of audio samples at `self.sample_rate`.
|
|
22
|
+
A concrete adapter may accept additional, engine-specific keyword
|
|
23
|
+
arguments beyond this minimal signature (e.g. `KokoroDirectTTS`
|
|
24
|
+
accepts an optional `lang_code` override) — callers that don't need
|
|
25
|
+
them can ignore this possibility entirely.
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""KokoroDirectTTS: in-process Kokoro TTS adapter.
|
|
2
|
+
|
|
3
|
+
Loads the Kokoro model directly into the calling process (no server, no
|
|
4
|
+
network hop) and keeps it resident in memory, favoring simplicity and lower
|
|
5
|
+
latency over sharing one server across processes. This is the chosen
|
|
6
|
+
approach for all current consumers (podcast generation, loremaster, Home
|
|
7
|
+
Assistant); revisit only if resource use across multiple resident processes
|
|
8
|
+
becomes a real problem.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
# Kokoro and torch are optional dependencies (the `kokoro` extra), so a
|
|
14
|
+
# missing one gets an install hint rather than a bare ModuleNotFoundError.
|
|
15
|
+
_MISSING_EXTRA = (
|
|
16
|
+
"KokoroDirectTTS needs the optional Kokoro dependencies: "
|
|
17
|
+
"pip install 'voxport[kokoro]'"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# voxport/tts/kokoro_direct.py -> voxport/tts -> voxport -> repo root
|
|
21
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
22
|
+
|
|
23
|
+
# In a source checkout, an existing `hf_cache/` folder at the repo root is
|
|
24
|
+
# used as the Hugging Face cache (HF_HOME), so a pre-populated cache can be
|
|
25
|
+
# copied between checkouts (e.g. onto a machine that can't reach
|
|
26
|
+
# huggingface.co). Only if that folder already exists: a regular pip install
|
|
27
|
+
# has no repo root (REPO_ROOT is site-packages), and there the standard
|
|
28
|
+
# Hugging Face cache location is used. An HF_HOME already set by the user
|
|
29
|
+
# always wins. Set before any Kokoro-related import.
|
|
30
|
+
_REPO_HF_CACHE = os.path.join(REPO_ROOT, "hf_cache")
|
|
31
|
+
if os.path.isdir(_REPO_HF_CACHE):
|
|
32
|
+
os.environ.setdefault("HF_HOME", _REPO_HF_CACHE)
|
|
33
|
+
|
|
34
|
+
# Offline mode is opt-in, not forced here: only a machine that actually needs
|
|
35
|
+
# it (e.g. one behind a firewall that blocks huggingface.co) should set
|
|
36
|
+
# HF_HUB_OFFLINE=1 itself, in its own shell/.env — this module never sets it,
|
|
37
|
+
# so a machine with normal internet access can still download any voice not
|
|
38
|
+
# already in the local hf_cache above.
|
|
39
|
+
|
|
40
|
+
# In conda/miniforge environments, PyTorch's bundled OpenMP runtime (libomp)
|
|
41
|
+
# can collide with Intel's (libiomp5md, pulled in by MKL), causing "OMP Error
|
|
42
|
+
# #15" and a failed import. Allowing the duplicate runtime is the standard,
|
|
43
|
+
# widely-used workaround for CPU inference. Set before torch is imported
|
|
44
|
+
# (Kokoro imports it lazily, inside _build_pipeline below).
|
|
45
|
+
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
|
|
46
|
+
|
|
47
|
+
# Import torch here, at module import time, before any caller has a chance to
|
|
48
|
+
# import numpy/soundfile/kokoro first and pull in Intel's OpenMP (MKL) runtime
|
|
49
|
+
# ahead of torch's own. The reverse order breaks torch's DLL resolution on
|
|
50
|
+
# Windows/conda with a WinError 127 on shm.dll — importing torch as the very
|
|
51
|
+
# first thing this module does, regardless of what a caller does afterward,
|
|
52
|
+
# is what makes that safe to rely on from any consumer.
|
|
53
|
+
try:
|
|
54
|
+
import torch # noqa: F401
|
|
55
|
+
except ImportError as exc:
|
|
56
|
+
raise ImportError(_MISSING_EXTRA) from exc
|
|
57
|
+
|
|
58
|
+
from voxport.tts.base import TTSEngine
|
|
59
|
+
|
|
60
|
+
# Kokoro produces audio at a fixed 24 kHz sample rate.
|
|
61
|
+
SAMPLE_RATE = 24000
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class KokoroDirectTTS(TTSEngine):
|
|
65
|
+
"""In-process Kokoro TTS, model loaded once per language and reused.
|
|
66
|
+
|
|
67
|
+
A single instance may be reused across many `synthesize()` calls —
|
|
68
|
+
pipelines are cached by language code, so a run that mixes accents
|
|
69
|
+
(e.g. an American and a British voice) loads each accent's model at
|
|
70
|
+
most once.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
sample_rate = SAMPLE_RATE
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._pipelines = {}
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _build_pipeline(lang_code):
|
|
80
|
+
# torch is already imported at module load time (see top of file);
|
|
81
|
+
# Kokoro is safe to import lazily here, on first actual use.
|
|
82
|
+
try:
|
|
83
|
+
from kokoro import KPipeline
|
|
84
|
+
except ImportError as exc:
|
|
85
|
+
raise ImportError(_MISSING_EXTRA) from exc
|
|
86
|
+
|
|
87
|
+
# Pass repo_id explicitly to suppress Kokoro's "defaulting repo_id"
|
|
88
|
+
# warning.
|
|
89
|
+
return KPipeline(lang_code=lang_code, repo_id="hexgrad/Kokoro-82M")
|
|
90
|
+
|
|
91
|
+
def _get_pipeline(self, lang_code):
|
|
92
|
+
if lang_code not in self._pipelines:
|
|
93
|
+
print(f"Loading Kokoro model for language '{lang_code}'...")
|
|
94
|
+
self._pipelines[lang_code] = self._build_pipeline(lang_code)
|
|
95
|
+
return self._pipelines[lang_code]
|
|
96
|
+
|
|
97
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0, lang_code: str | None = None):
|
|
98
|
+
"""Return synthesized audio for `text` as a single numpy array.
|
|
99
|
+
|
|
100
|
+
The accent/language pipeline is derived from the voice's first
|
|
101
|
+
letter ('a' American, 'b' British, ...), matching Kokoro's own
|
|
102
|
+
voice-naming convention, unless `lang_code` pins one explicitly.
|
|
103
|
+
|
|
104
|
+
Kokoro automatically splits `text` into natural segments (on
|
|
105
|
+
sentence and paragraph boundaries), so there is no character limit
|
|
106
|
+
to work around; the per-segment audio is concatenated before return.
|
|
107
|
+
"""
|
|
108
|
+
import numpy as np
|
|
109
|
+
|
|
110
|
+
pipeline = self._get_pipeline(lang_code or voice[0])
|
|
111
|
+
|
|
112
|
+
print(f"Generating audio locally with Kokoro (voice={voice}, speed={speed})...")
|
|
113
|
+
|
|
114
|
+
segments = []
|
|
115
|
+
generator = pipeline(text, voice=voice, speed=speed)
|
|
116
|
+
for index, (_graphemes, _phonemes, audio) in enumerate(generator, start=1):
|
|
117
|
+
print(f" Segment {index}...")
|
|
118
|
+
# `audio` may be a torch tensor or a numpy array; normalize to numpy.
|
|
119
|
+
if hasattr(audio, "detach"):
|
|
120
|
+
audio = audio.detach().cpu().numpy()
|
|
121
|
+
else:
|
|
122
|
+
audio = np.asarray(audio)
|
|
123
|
+
segments.append(audio)
|
|
124
|
+
|
|
125
|
+
if not segments:
|
|
126
|
+
raise ValueError("Kokoro produced no audio segments for the given text.")
|
|
127
|
+
|
|
128
|
+
return np.concatenate(segments)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voxport
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Text-to-speech and speech-to-text interfaces with swappable engine adapters (Kokoro, Wyoming Whisper).
|
|
5
|
+
Author: Brien Alkire
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/brienalkire/voxport
|
|
8
|
+
Project-URL: Repository, https://github.com/brienalkire/voxport
|
|
9
|
+
Project-URL: Changelog, https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/brienalkire/voxport/issues
|
|
11
|
+
Keywords: tts,stt,text-to-speech,speech-to-text,speech,voice,kokoro,whisper,wyoming
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=2.0
|
|
23
|
+
Provides-Extra: kokoro
|
|
24
|
+
Requires-Dist: kokoro>=0.9.4; extra == "kokoro"
|
|
25
|
+
Requires-Dist: torch>=2.12.0; extra == "kokoro"
|
|
26
|
+
Provides-Extra: wyoming
|
|
27
|
+
Requires-Dist: wyoming>=1.5.0; extra == "wyoming"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: voxport[kokoro,wyoming]; extra == "all"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# voxport
|
|
33
|
+
|
|
34
|
+
Small Python interfaces for text-to-speech and speech-to-text, with
|
|
35
|
+
swappable engine adapters. Your code calls one method,
|
|
36
|
+
`synthesize()` or `transcribe()`, and never touches an engine's API
|
|
37
|
+
directly, so switching engines means changing one line where the adapter
|
|
38
|
+
is created.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
TTSEngine.synthesize(text, voice) -> audio
|
|
42
|
+
STTEngine.transcribe(audio) -> text
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Included adapters:
|
|
46
|
+
|
|
47
|
+
- **`KokoroDirectTTS`**: runs the [Kokoro](https://huggingface.co/hexgrad/Kokoro-82M)
|
|
48
|
+
TTS model in your own process. The model loads once and stays in
|
|
49
|
+
memory, with no server and no network hop.
|
|
50
|
+
- **`WyomingWhisperSTT`**: a client for a [Wyoming](https://github.com/OHF-Voice/wyoming)
|
|
51
|
+
protocol Whisper server, the same kind of server Home Assistant's voice
|
|
52
|
+
pipeline uses. It loads no model itself; transcription happens on the
|
|
53
|
+
server.
|
|
54
|
+
|
|
55
|
+
voxport is a library only: it has no command-line tool or app of its
|
|
56
|
+
own. It is pre-1.0, so the API may still change between minor versions
|
|
57
|
+
(see the [changelog](https://github.com/brienalkire/voxport/blob/main/CHANGELOG.md)).
|
|
58
|
+
|
|
59
|
+
## Installation
|
|
60
|
+
|
|
61
|
+
Requires Python 3.10+. Each adapter's dependencies are an optional
|
|
62
|
+
extra, so you install only what you use:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pip install "voxport[kokoro]" # KokoroDirectTTS
|
|
66
|
+
pip install "voxport[wyoming]" # WyomingWhisperSTT
|
|
67
|
+
pip install "voxport[all]" # both
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
| Extra | Adds | For |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `kokoro` | Kokoro, torch (about 2 GB) | `KokoroDirectTTS` |
|
|
73
|
+
| `wyoming` | wyoming | `WyomingWhisperSTT` |
|
|
74
|
+
| `all` | both of the above | everything |
|
|
75
|
+
|
|
76
|
+
A plain `pip install voxport` includes only the interfaces and numpy.
|
|
77
|
+
Importing an adapter without its extra raises an `ImportError` that
|
|
78
|
+
names the extra to install.
|
|
79
|
+
|
|
80
|
+
Things voxport doesn't install for you:
|
|
81
|
+
|
|
82
|
+
- `WyomingWhisperSTT` needs a running Wyoming Whisper server. See
|
|
83
|
+
[Running a Wyoming Whisper server](#running-a-wyoming-whisper-server).
|
|
84
|
+
- On first use, Kokoro downloads its model weights and voice packs from
|
|
85
|
+
Hugging Face, and spaCy's `en_core_web_sm` English model. See
|
|
86
|
+
[Offline use](#offline-use) for machines that can't reach those.
|
|
87
|
+
- `soundfile`, used in the examples below to write WAV files.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
### Text to speech
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import soundfile as sf
|
|
95
|
+
from voxport.tts.kokoro_direct import KokoroDirectTTS
|
|
96
|
+
|
|
97
|
+
tts = KokoroDirectTTS() # reuse one instance; models load once per accent
|
|
98
|
+
audio = tts.synthesize("Hello from voxport.", voice="af_heart", speed=1.0)
|
|
99
|
+
sf.write("hello.wav", audio, tts.sample_rate) # 24 kHz mono
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`synthesize()` returns a 1-D numpy array. The first letter of the voice
|
|
103
|
+
name picks the accent (`a` American, `b` British, ...), following
|
|
104
|
+
Kokoro's voice naming; pass `lang_code=` to override it. Kokoro splits
|
|
105
|
+
long text into segments itself, so there is no length limit.
|
|
106
|
+
|
|
107
|
+
### Speech to text
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from voxport.stt.wyoming_whisper import WyomingWhisperSTT
|
|
111
|
+
|
|
112
|
+
stt = WyomingWhisperSTT(host="localhost", port=10300) # the defaults
|
|
113
|
+
text = stt.transcribe(audio_16k, language="en")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`audio_16k` is a 1-D numpy array of mono audio at `stt.sample_rate`
|
|
117
|
+
(16 kHz), either int16 samples or floats in [-1, 1]. Resample first if
|
|
118
|
+
your audio has a different rate; Kokoro's output, for example, is
|
|
119
|
+
24 kHz. Each call opens a new connection to the server.
|
|
120
|
+
|
|
121
|
+
### From async code
|
|
122
|
+
|
|
123
|
+
Both calls block, and `transcribe()` runs its own asyncio event loop, so
|
|
124
|
+
from code that already has a running loop, call them in a worker thread:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
audio = await asyncio.to_thread(tts.synthesize, text, "bm_george")
|
|
128
|
+
text = await asyncio.to_thread(stt.transcribe, audio_16k, "en")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Writing your own adapter
|
|
132
|
+
|
|
133
|
+
An adapter subclasses `TTSEngine` or `STTEngine`, sets `sample_rate`,
|
|
134
|
+
and implements the one abstract method:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
import numpy as np
|
|
138
|
+
from voxport.tts.base import TTSEngine
|
|
139
|
+
|
|
140
|
+
class SilenceTTS(TTSEngine):
|
|
141
|
+
"""A toy engine: one second of silence, whatever the text."""
|
|
142
|
+
|
|
143
|
+
sample_rate = 16000
|
|
144
|
+
|
|
145
|
+
def synthesize(self, text: str, voice: str, speed: float = 1.0):
|
|
146
|
+
return np.zeros(self.sample_rate, dtype=np.float32)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Code written against `TTSEngine` works with `SilenceTTS` and
|
|
150
|
+
`KokoroDirectTTS` unchanged. Adapters may accept extra, engine-specific
|
|
151
|
+
keyword arguments (like `KokoroDirectTTS`'s `lang_code`), as long as the
|
|
152
|
+
base signature still works.
|
|
153
|
+
|
|
154
|
+
## Offline use
|
|
155
|
+
|
|
156
|
+
By default, Kokoro's weights and voice packs download to Hugging Face's
|
|
157
|
+
standard cache (`~/.cache/huggingface`, or wherever `HF_HOME` points).
|
|
158
|
+
To run on a machine that can't reach huggingface.co:
|
|
159
|
+
|
|
160
|
+
1. On a machine with internet access, synthesize once with each voice
|
|
161
|
+
you plan to use, so the model and those voice packs are cached.
|
|
162
|
+
2. Copy that cache to the offline machine, keeping Hugging Face's
|
|
163
|
+
`hub/models--<org>--<name>/...` layout, and point `HF_HOME` at it.
|
|
164
|
+
3. Set `HF_HUB_OFFLINE=1` on the offline machine. voxport never sets
|
|
165
|
+
this for you. With it set, only voices already in the cache work; an
|
|
166
|
+
uncached voice fails with a Hub connection error instead of
|
|
167
|
+
downloading.
|
|
168
|
+
|
|
169
|
+
Kokoro's English phonemizer also needs spaCy's `en_core_web_sm` model,
|
|
170
|
+
which downloads from GitHub, not Hugging Face, so `HF_HUB_OFFLINE`
|
|
171
|
+
doesn't cover it. It downloads once per Python environment, so install
|
|
172
|
+
it while online:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
python -m spacy download en_core_web_sm
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Repo-local cache (source checkouts only).** If you run voxport from a
|
|
179
|
+
clone, you can keep the cache inside it instead: create an `hf_cache/`
|
|
180
|
+
folder at the repo root (it's gitignored) and `KokoroDirectTTS` uses it
|
|
181
|
+
as `HF_HOME`, unless you've set `HF_HOME` yourself. That makes the
|
|
182
|
+
cache easy to copy along with the checkout.
|
|
183
|
+
|
|
184
|
+
## Running a Wyoming Whisper server
|
|
185
|
+
|
|
186
|
+
`WyomingWhisperSTT` needs a server to talk to. For local testing, the
|
|
187
|
+
easiest option is the [`rhasspy/wyoming-whisper`](https://github.com/rhasspy/wyoming-faster-whisper)
|
|
188
|
+
Docker image:
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
docker run -d --name whisper --restart unless-stopped -p 10300:10300 -v wyoming-data:/data rhasspy/wyoming-whisper --model tiny-int8 --language en
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The image is named `rhasspy/wyoming-whisper`, even though its GitHub
|
|
195
|
+
repo is `wyoming-faster-whisper`. `docker logs whisper` shows `Ready`
|
|
196
|
+
once the model has loaded. `tiny-int8` is small and fast but not very
|
|
197
|
+
accurate; use a larger model when transcription quality matters.
|
|
198
|
+
|
|
199
|
+
If you already run Home Assistant with its Whisper add-on, you can point
|
|
200
|
+
`WyomingWhisperSTT` at that server's host and port instead.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
git clone https://github.com/brienalkire/voxport.git
|
|
206
|
+
cd voxport
|
|
207
|
+
pip install -e ".[all]" pytest
|
|
208
|
+
python -m pytest
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The tests need no network, no Whisper server and no Kokoro model:
|
|
212
|
+
`WyomingWhisperSTT` is tested against a fake in-process Wyoming server.
|
|
213
|
+
Installing `.[wyoming]` instead of `.[all]` is enough to run them and
|
|
214
|
+
skips the 2 GB torch download.
|
|
215
|
+
|
|
216
|
+
## License
|
|
217
|
+
|
|
218
|
+
[MIT](https://github.com/brienalkire/voxport/blob/main/LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_interfaces.py
|
|
5
|
+
tests/test_optional_extras.py
|
|
6
|
+
tests/test_wyoming_whisper.py
|
|
7
|
+
voxport/__init__.py
|
|
8
|
+
voxport.egg-info/PKG-INFO
|
|
9
|
+
voxport.egg-info/SOURCES.txt
|
|
10
|
+
voxport.egg-info/dependency_links.txt
|
|
11
|
+
voxport.egg-info/requires.txt
|
|
12
|
+
voxport.egg-info/top_level.txt
|
|
13
|
+
voxport/stt/__init__.py
|
|
14
|
+
voxport/stt/base.py
|
|
15
|
+
voxport/stt/wyoming_whisper.py
|
|
16
|
+
voxport/tts/__init__.py
|
|
17
|
+
voxport/tts/base.py
|
|
18
|
+
voxport/tts/kokoro_direct.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
voxport
|