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 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)
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,11 @@
1
+ numpy>=2.0
2
+
3
+ [all]
4
+ voxport[kokoro,wyoming]
5
+
6
+ [kokoro]
7
+ kokoro>=0.9.4
8
+ torch>=2.12.0
9
+
10
+ [wyoming]
11
+ wyoming>=1.5.0
@@ -0,0 +1 @@
1
+ voxport