huri-module-tts 0.1.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.
- huri_module_tts-0.1.0/PKG-INFO +84 -0
- huri_module_tts-0.1.0/README.md +69 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/PKG-INFO +84 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/SOURCES.txt +21 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/dependency_links.txt +1 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/entry_points.txt +3 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/requires.txt +10 -0
- huri_module_tts-0.1.0/huri_module_tts.egg-info/top_level.txt +1 -0
- huri_module_tts-0.1.0/huri_tts/__init__.py +4 -0
- huri_module_tts-0.1.0/huri_tts/cosyvoice/__init__.py +0 -0
- huri_module_tts-0.1.0/huri_tts/cosyvoice/handle.py +222 -0
- huri_module_tts-0.1.0/huri_tts/cosyvoice/module.py +11 -0
- huri_module_tts-0.1.0/huri_tts/manifests/tts/huri_module.toml +7 -0
- huri_module_tts-0.1.0/huri_tts/manifests/tts_cosyvoice/huri_module.toml +7 -0
- huri_module_tts-0.1.0/huri_tts/piper/__init__.py +0 -0
- huri_module_tts-0.1.0/huri_tts/piper/handle.py +182 -0
- huri_module_tts-0.1.0/huri_tts/piper/module.py +11 -0
- huri_module_tts-0.1.0/huri_tts/streaming.py +130 -0
- huri_module_tts-0.1.0/pyproject.toml +41 -0
- huri_module_tts-0.1.0/setup.cfg +4 -0
- huri_module_tts-0.1.0/tests/test_cosyvoice.py +44 -0
- huri_module_tts-0.1.0/tests/test_piper.py +78 -0
- huri_module_tts-0.1.0/tests/test_streaming_tts.py +75 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: huri-module-tts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HuRI modules: text to speech with Piper (CPU, any machine) or CosyVoice3 (voice cloning, NVIDIA GPU)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: huri-core[server]<0.5,>=0.4
|
|
8
|
+
Requires-Dist: huri-events<0.2,>=0.1
|
|
9
|
+
Requires-Dist: numpy<2,>=1.26
|
|
10
|
+
Provides-Extra: piper
|
|
11
|
+
Requires-Dist: piper-tts<1.9,>=1.8; extra == "piper"
|
|
12
|
+
Provides-Extra: cosyvoice
|
|
13
|
+
Requires-Dist: torch; extra == "cosyvoice"
|
|
14
|
+
Requires-Dist: torchaudio; extra == "cosyvoice"
|
|
15
|
+
|
|
16
|
+
# huri-module-tts
|
|
17
|
+
|
|
18
|
+
HuRI's text-to-speech modules: they speak the assistant's reply. One package,
|
|
19
|
+
two engines, each a module of its own with its own dependencies (an extra), so
|
|
20
|
+
a host installs only the engine it runs:
|
|
21
|
+
|
|
22
|
+
| Module | Engine | Extra | Topics | Deployment |
|
|
23
|
+
|---|---|---|---|---|
|
|
24
|
+
| `tts` | [Piper](https://github.com/OHF-Voice/piper1-gpl): ONNX only, no torch, no GPU, ~30x faster than realtime on a laptop CPU. The default: it runs on any machine. | `piper` | `huri.token` → `huri.audio.out` | `TTSHandle` |
|
|
25
|
+
| `tts_cosyvoice` | CosyVoice3: clones the voice of a reference sample. Needs an NVIDIA GPU for real time (~7x slower than realtime on CPU; its vocoder faults on consumer RDNA3 GPUs). | `cosyvoice` | `huri.token` → `huri.audio.out` | `CosyVoiceTTSHandle` |
|
|
26
|
+
|
|
27
|
+
Both are built on `huri_tts.streaming.StreamingTTS`, the engine-agnostic half:
|
|
28
|
+
it opens one synthesis session per utterance, pushes each `huri.token` into it
|
|
29
|
+
in order and streams `huri.audio.out` back with presentation timestamps.
|
|
30
|
+
|
|
31
|
+
The base install has no engine library; `[tool.huri-modules.extras]` in
|
|
32
|
+
`pyproject.toml` names the extra each module needs (`huri hub install` and
|
|
33
|
+
`scripts/install_local.sh` add it). CosyVoice's hardware pins are
|
|
34
|
+
`requirements-<variant>.tts_cosyvoice.txt`: only a host that plans
|
|
35
|
+
`tts_cosyvoice` gets them, so they never meet Piper's `onnxruntime`.
|
|
36
|
+
|
|
37
|
+
Upgrading from the three packages this one replaced (huri-tts,
|
|
38
|
+
huri-module-tts-piper, huri-module-tts-cosyvoice): `pip uninstall huri-tts
|
|
39
|
+
huri-module-tts-piper huri-module-tts-cosyvoice` first (`./install.sh` does
|
|
40
|
+
it). Left installed, they claim the module names `tts` and `tts_cosyvoice` too,
|
|
41
|
+
and huri-core then loads no module at all.
|
|
42
|
+
|
|
43
|
+
Piper (`tts`):
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install -e 'packages/huri-module-tts[piper]'
|
|
47
|
+
python -m piper.download_voices --download-dir assets/models/piper en_US-lessac-medium
|
|
48
|
+
HURI_PIPER_VOICE=assets/models/piper/en_US-lessac-medium.onnx HURI_MODULES=...,tts ...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
CosyVoice3 (`tts_cosyvoice`):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install -c constraints.txt --extra-index-url https://download.pytorch.org/whl/cu121 \
|
|
55
|
+
-r packages/huri-module-tts/requirements-nvidia.tts_cosyvoice.txt
|
|
56
|
+
pip install -e 'packages/huri-module-tts[cosyvoice]'
|
|
57
|
+
packages/huri-module-tts/install_cosyvoice.sh /opt/cosyvoice # not pip-installable
|
|
58
|
+
export HURI_COSY_DIR=/opt/cosyvoice # the handle puts it and its Matcha-TTS on sys.path
|
|
59
|
+
HURI_MODULES=...,tts_cosyvoice ... # instead of tts; see the env vars in huri_tts/cosyvoice/handle.py
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`huri hub install tts_cosyvoice` (or `pip install
|
|
63
|
+
'huri-module-tts[cosyvoice]'`) does only the second line: torch and torchaudio,
|
|
64
|
+
unpinned. The pins, the CosyVoice checkout and `HURI_COSY_DIR` stay the steps
|
|
65
|
+
above (`./install.sh --tts-engine cosyvoice` does all of them).
|
|
66
|
+
|
|
67
|
+
Use `tts_cosyvoice` in place of `tts` in the server's `HURI_MODULES` and the
|
|
68
|
+
client's `modules:`, and name its Serve deployment `CosyVoiceTTSHandle`.
|
|
69
|
+
|
|
70
|
+
A distribution of
|
|
71
|
+
[huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
|
|
72
|
+
`pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
|
|
73
|
+
`huri-events`, which are not on PyPI: without a checkout, install them from git
|
|
74
|
+
in the same pip command, so pip never looks them up on an index (`[cosyvoice]`
|
|
75
|
+
for the other engine):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
ref=0.1.0 # a huri-modules release tag
|
|
79
|
+
core=0.4.0 # HURI_CORE_REF in that release's versions.env
|
|
80
|
+
pip install \
|
|
81
|
+
"huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
|
|
82
|
+
"huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
|
|
83
|
+
"huri-module-tts[piper] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-tts"
|
|
84
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# huri-module-tts
|
|
2
|
+
|
|
3
|
+
HuRI's text-to-speech modules: they speak the assistant's reply. One package,
|
|
4
|
+
two engines, each a module of its own with its own dependencies (an extra), so
|
|
5
|
+
a host installs only the engine it runs:
|
|
6
|
+
|
|
7
|
+
| Module | Engine | Extra | Topics | Deployment |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| `tts` | [Piper](https://github.com/OHF-Voice/piper1-gpl): ONNX only, no torch, no GPU, ~30x faster than realtime on a laptop CPU. The default: it runs on any machine. | `piper` | `huri.token` → `huri.audio.out` | `TTSHandle` |
|
|
10
|
+
| `tts_cosyvoice` | CosyVoice3: clones the voice of a reference sample. Needs an NVIDIA GPU for real time (~7x slower than realtime on CPU; its vocoder faults on consumer RDNA3 GPUs). | `cosyvoice` | `huri.token` → `huri.audio.out` | `CosyVoiceTTSHandle` |
|
|
11
|
+
|
|
12
|
+
Both are built on `huri_tts.streaming.StreamingTTS`, the engine-agnostic half:
|
|
13
|
+
it opens one synthesis session per utterance, pushes each `huri.token` into it
|
|
14
|
+
in order and streams `huri.audio.out` back with presentation timestamps.
|
|
15
|
+
|
|
16
|
+
The base install has no engine library; `[tool.huri-modules.extras]` in
|
|
17
|
+
`pyproject.toml` names the extra each module needs (`huri hub install` and
|
|
18
|
+
`scripts/install_local.sh` add it). CosyVoice's hardware pins are
|
|
19
|
+
`requirements-<variant>.tts_cosyvoice.txt`: only a host that plans
|
|
20
|
+
`tts_cosyvoice` gets them, so they never meet Piper's `onnxruntime`.
|
|
21
|
+
|
|
22
|
+
Upgrading from the three packages this one replaced (huri-tts,
|
|
23
|
+
huri-module-tts-piper, huri-module-tts-cosyvoice): `pip uninstall huri-tts
|
|
24
|
+
huri-module-tts-piper huri-module-tts-cosyvoice` first (`./install.sh` does
|
|
25
|
+
it). Left installed, they claim the module names `tts` and `tts_cosyvoice` too,
|
|
26
|
+
and huri-core then loads no module at all.
|
|
27
|
+
|
|
28
|
+
Piper (`tts`):
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install -e 'packages/huri-module-tts[piper]'
|
|
32
|
+
python -m piper.download_voices --download-dir assets/models/piper en_US-lessac-medium
|
|
33
|
+
HURI_PIPER_VOICE=assets/models/piper/en_US-lessac-medium.onnx HURI_MODULES=...,tts ...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
CosyVoice3 (`tts_cosyvoice`):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install -c constraints.txt --extra-index-url https://download.pytorch.org/whl/cu121 \
|
|
40
|
+
-r packages/huri-module-tts/requirements-nvidia.tts_cosyvoice.txt
|
|
41
|
+
pip install -e 'packages/huri-module-tts[cosyvoice]'
|
|
42
|
+
packages/huri-module-tts/install_cosyvoice.sh /opt/cosyvoice # not pip-installable
|
|
43
|
+
export HURI_COSY_DIR=/opt/cosyvoice # the handle puts it and its Matcha-TTS on sys.path
|
|
44
|
+
HURI_MODULES=...,tts_cosyvoice ... # instead of tts; see the env vars in huri_tts/cosyvoice/handle.py
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`huri hub install tts_cosyvoice` (or `pip install
|
|
48
|
+
'huri-module-tts[cosyvoice]'`) does only the second line: torch and torchaudio,
|
|
49
|
+
unpinned. The pins, the CosyVoice checkout and `HURI_COSY_DIR` stay the steps
|
|
50
|
+
above (`./install.sh --tts-engine cosyvoice` does all of them).
|
|
51
|
+
|
|
52
|
+
Use `tts_cosyvoice` in place of `tts` in the server's `HURI_MODULES` and the
|
|
53
|
+
client's `modules:`, and name its Serve deployment `CosyVoiceTTSHandle`.
|
|
54
|
+
|
|
55
|
+
A distribution of
|
|
56
|
+
[huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
|
|
57
|
+
`pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
|
|
58
|
+
`huri-events`, which are not on PyPI: without a checkout, install them from git
|
|
59
|
+
in the same pip command, so pip never looks them up on an index (`[cosyvoice]`
|
|
60
|
+
for the other engine):
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
ref=0.1.0 # a huri-modules release tag
|
|
64
|
+
core=0.4.0 # HURI_CORE_REF in that release's versions.env
|
|
65
|
+
pip install \
|
|
66
|
+
"huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
|
|
67
|
+
"huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
|
|
68
|
+
"huri-module-tts[piper] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-tts"
|
|
69
|
+
```
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: huri-module-tts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HuRI modules: text to speech with Piper (CPU, any machine) or CosyVoice3 (voice cloning, NVIDIA GPU)
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: huri-core[server]<0.5,>=0.4
|
|
8
|
+
Requires-Dist: huri-events<0.2,>=0.1
|
|
9
|
+
Requires-Dist: numpy<2,>=1.26
|
|
10
|
+
Provides-Extra: piper
|
|
11
|
+
Requires-Dist: piper-tts<1.9,>=1.8; extra == "piper"
|
|
12
|
+
Provides-Extra: cosyvoice
|
|
13
|
+
Requires-Dist: torch; extra == "cosyvoice"
|
|
14
|
+
Requires-Dist: torchaudio; extra == "cosyvoice"
|
|
15
|
+
|
|
16
|
+
# huri-module-tts
|
|
17
|
+
|
|
18
|
+
HuRI's text-to-speech modules: they speak the assistant's reply. One package,
|
|
19
|
+
two engines, each a module of its own with its own dependencies (an extra), so
|
|
20
|
+
a host installs only the engine it runs:
|
|
21
|
+
|
|
22
|
+
| Module | Engine | Extra | Topics | Deployment |
|
|
23
|
+
|---|---|---|---|---|
|
|
24
|
+
| `tts` | [Piper](https://github.com/OHF-Voice/piper1-gpl): ONNX only, no torch, no GPU, ~30x faster than realtime on a laptop CPU. The default: it runs on any machine. | `piper` | `huri.token` → `huri.audio.out` | `TTSHandle` |
|
|
25
|
+
| `tts_cosyvoice` | CosyVoice3: clones the voice of a reference sample. Needs an NVIDIA GPU for real time (~7x slower than realtime on CPU; its vocoder faults on consumer RDNA3 GPUs). | `cosyvoice` | `huri.token` → `huri.audio.out` | `CosyVoiceTTSHandle` |
|
|
26
|
+
|
|
27
|
+
Both are built on `huri_tts.streaming.StreamingTTS`, the engine-agnostic half:
|
|
28
|
+
it opens one synthesis session per utterance, pushes each `huri.token` into it
|
|
29
|
+
in order and streams `huri.audio.out` back with presentation timestamps.
|
|
30
|
+
|
|
31
|
+
The base install has no engine library; `[tool.huri-modules.extras]` in
|
|
32
|
+
`pyproject.toml` names the extra each module needs (`huri hub install` and
|
|
33
|
+
`scripts/install_local.sh` add it). CosyVoice's hardware pins are
|
|
34
|
+
`requirements-<variant>.tts_cosyvoice.txt`: only a host that plans
|
|
35
|
+
`tts_cosyvoice` gets them, so they never meet Piper's `onnxruntime`.
|
|
36
|
+
|
|
37
|
+
Upgrading from the three packages this one replaced (huri-tts,
|
|
38
|
+
huri-module-tts-piper, huri-module-tts-cosyvoice): `pip uninstall huri-tts
|
|
39
|
+
huri-module-tts-piper huri-module-tts-cosyvoice` first (`./install.sh` does
|
|
40
|
+
it). Left installed, they claim the module names `tts` and `tts_cosyvoice` too,
|
|
41
|
+
and huri-core then loads no module at all.
|
|
42
|
+
|
|
43
|
+
Piper (`tts`):
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install -e 'packages/huri-module-tts[piper]'
|
|
47
|
+
python -m piper.download_voices --download-dir assets/models/piper en_US-lessac-medium
|
|
48
|
+
HURI_PIPER_VOICE=assets/models/piper/en_US-lessac-medium.onnx HURI_MODULES=...,tts ...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
CosyVoice3 (`tts_cosyvoice`):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
pip install -c constraints.txt --extra-index-url https://download.pytorch.org/whl/cu121 \
|
|
55
|
+
-r packages/huri-module-tts/requirements-nvidia.tts_cosyvoice.txt
|
|
56
|
+
pip install -e 'packages/huri-module-tts[cosyvoice]'
|
|
57
|
+
packages/huri-module-tts/install_cosyvoice.sh /opt/cosyvoice # not pip-installable
|
|
58
|
+
export HURI_COSY_DIR=/opt/cosyvoice # the handle puts it and its Matcha-TTS on sys.path
|
|
59
|
+
HURI_MODULES=...,tts_cosyvoice ... # instead of tts; see the env vars in huri_tts/cosyvoice/handle.py
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`huri hub install tts_cosyvoice` (or `pip install
|
|
63
|
+
'huri-module-tts[cosyvoice]'`) does only the second line: torch and torchaudio,
|
|
64
|
+
unpinned. The pins, the CosyVoice checkout and `HURI_COSY_DIR` stay the steps
|
|
65
|
+
above (`./install.sh --tts-engine cosyvoice` does all of them).
|
|
66
|
+
|
|
67
|
+
Use `tts_cosyvoice` in place of `tts` in the server's `HURI_MODULES` and the
|
|
68
|
+
client's `modules:`, and name its Serve deployment `CosyVoiceTTSHandle`.
|
|
69
|
+
|
|
70
|
+
A distribution of
|
|
71
|
+
[huri-modules](https://github.com/Lucy-Robotics/huri-modules), versioned in its
|
|
72
|
+
`pyproject.toml` (see RELEASING.md there). It depends on `huri-core` and
|
|
73
|
+
`huri-events`, which are not on PyPI: without a checkout, install them from git
|
|
74
|
+
in the same pip command, so pip never looks them up on an index (`[cosyvoice]`
|
|
75
|
+
for the other engine):
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
ref=0.1.0 # a huri-modules release tag
|
|
79
|
+
core=0.4.0 # HURI_CORE_REF in that release's versions.env
|
|
80
|
+
pip install \
|
|
81
|
+
"huri-core[server] @ git+https://github.com/Lucy-Robotics/huri-core.git@$core" \
|
|
82
|
+
"huri-events @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-events" \
|
|
83
|
+
"huri-module-tts[piper] @ git+https://github.com/Lucy-Robotics/huri-modules.git@$ref#subdirectory=packages/huri-module-tts"
|
|
84
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
huri_module_tts.egg-info/PKG-INFO
|
|
4
|
+
huri_module_tts.egg-info/SOURCES.txt
|
|
5
|
+
huri_module_tts.egg-info/dependency_links.txt
|
|
6
|
+
huri_module_tts.egg-info/entry_points.txt
|
|
7
|
+
huri_module_tts.egg-info/requires.txt
|
|
8
|
+
huri_module_tts.egg-info/top_level.txt
|
|
9
|
+
huri_tts/__init__.py
|
|
10
|
+
huri_tts/streaming.py
|
|
11
|
+
huri_tts/cosyvoice/__init__.py
|
|
12
|
+
huri_tts/cosyvoice/handle.py
|
|
13
|
+
huri_tts/cosyvoice/module.py
|
|
14
|
+
huri_tts/manifests/tts/huri_module.toml
|
|
15
|
+
huri_tts/manifests/tts_cosyvoice/huri_module.toml
|
|
16
|
+
huri_tts/piper/__init__.py
|
|
17
|
+
huri_tts/piper/handle.py
|
|
18
|
+
huri_tts/piper/module.py
|
|
19
|
+
tests/test_cosyvoice.py
|
|
20
|
+
tests/test_piper.py
|
|
21
|
+
tests/test_streaming_tts.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
huri_tts
|
|
File without changes
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import AsyncGenerator, Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from huri_events.audio import Audio
|
|
10
|
+
from ray import serve
|
|
11
|
+
|
|
12
|
+
# Defaults — overridden by env vars in production (see README.md)
|
|
13
|
+
_MODEL_PATH = os.environ.get(
|
|
14
|
+
"HURI_MODEL_PATH", "/models/cosytts/FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
|
|
15
|
+
)
|
|
16
|
+
_VOICE_SAMPLE_PATH = os.environ.get("HURI_VOICE_SAMPLE_PATH", "/assets/voice.wav")
|
|
17
|
+
_DEFAULT_INSTRUCTION = "You are a helpful assistant."
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_transcript(raw: str) -> str:
|
|
21
|
+
"""Make a reference transcript safe for CosyVoice3.
|
|
22
|
+
|
|
23
|
+
CosyVoice3 expects "<instruction><|endofprompt|><transcript-of-voice.wav>".
|
|
24
|
+
If the configured transcript supplies a bare transcript (no marker), prepend
|
|
25
|
+
the default instruction so the transcript lands AFTER <|endofprompt|> —
|
|
26
|
+
otherwise the LM treats it as an instruction and intermittently renders it as
|
|
27
|
+
speech (prompt leakage).
|
|
28
|
+
"""
|
|
29
|
+
return (
|
|
30
|
+
raw
|
|
31
|
+
if "<|endofprompt|>" in raw
|
|
32
|
+
else f"{_DEFAULT_INSTRUCTION}<|endofprompt|>{raw}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _add_cosyvoice_to_path(cosy_dir: str) -> None:
|
|
37
|
+
"""Make a CosyVoice checkout importable: its root and Matcha-TTS submodule.
|
|
38
|
+
|
|
39
|
+
CosyVoice is not pip-installable, so `HURI_COSY_DIR` stands in for a
|
|
40
|
+
PYTHONPATH entry and the Serve config needs no absolute, per-host path.
|
|
41
|
+
"""
|
|
42
|
+
for path in (cosy_dir, os.path.join(cosy_dir, "third_party", "Matcha-TTS")):
|
|
43
|
+
path = os.path.abspath(path)
|
|
44
|
+
if os.path.isdir(path) and path not in sys.path:
|
|
45
|
+
sys.path.insert(0, path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_END_TEXT = object() # sentinel pushed into the text queue to close synth
|
|
49
|
+
_END_AUDIO = object() # sentinel pushed into the audio queue when synth completes
|
|
50
|
+
_DONE = object() # sentinel for exhausted sync generator
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@serve.deployment(name="CosyVoiceTTSHandle", max_ongoing_requests=200)
|
|
54
|
+
class CosyVoiceTTSHandle:
|
|
55
|
+
"""CosyVoice3 wrapper with per-session bistream synthesis.
|
|
56
|
+
|
|
57
|
+
The model's `inference_zero_shot` accepts a Python generator as `tts_text`
|
|
58
|
+
and yields audio chunks as text arrives — that's the "bistream" mode.
|
|
59
|
+
Because the model call is fully synchronous, each session runs in a thread
|
|
60
|
+
via `run_in_executor` and is fed by a thread-safe `queue.Queue` that the
|
|
61
|
+
asyncio side pushes text into.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
model_path: str = _MODEL_PATH,
|
|
67
|
+
voice_sample_path: str = _VOICE_SAMPLE_PATH,
|
|
68
|
+
voice_sample_transcript: Optional[str] = None,
|
|
69
|
+
):
|
|
70
|
+
cosy_dir = os.environ.get("HURI_COSY_DIR")
|
|
71
|
+
if cosy_dir:
|
|
72
|
+
_add_cosyvoice_to_path(cosy_dir)
|
|
73
|
+
|
|
74
|
+
from cosyvoice.cli.cosyvoice import CosyVoice3
|
|
75
|
+
|
|
76
|
+
# Resolve the reference transcript here (deploy time on the GPU worker)
|
|
77
|
+
# rather than at module import: importing this module must not require
|
|
78
|
+
# HURI_VOICE_TRANSCRIPT, since modules.py imports it inside a broad
|
|
79
|
+
# try/except that would otherwise make TTS silently vanish from the
|
|
80
|
+
# pipeline when the var is unset. Fail loudly and locally instead.
|
|
81
|
+
if voice_sample_transcript is None:
|
|
82
|
+
raw = os.environ.get("HURI_VOICE_TRANSCRIPT")
|
|
83
|
+
if not raw:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"HURI_VOICE_TRANSCRIPT is not set. The TTS deployment needs the "
|
|
86
|
+
"transcript of the reference voice sample (voice.wav). Set it in "
|
|
87
|
+
"the Serve app runtime_env.env_vars (see deploy values.yaml)."
|
|
88
|
+
)
|
|
89
|
+
voice_sample_transcript = raw
|
|
90
|
+
voice_sample_transcript = _normalize_transcript(voice_sample_transcript)
|
|
91
|
+
|
|
92
|
+
# fp16 is the whole point of running on a bandwidth-rich GPU (e.g. V100):
|
|
93
|
+
# it routes CosyVoice's LM/flow/vocoder matmuls through the fp16 tensor
|
|
94
|
+
# cores and halves memory traffic. CosyVoice3 defaults fp16=False (fp32),
|
|
95
|
+
# which leaves that advantage entirely unused. Default ON when a CUDA
|
|
96
|
+
# device is present; override with HURI_TTS_FP16 (fp16 needs CUDA, so it
|
|
97
|
+
# is forced off on CPU regardless).
|
|
98
|
+
import torch
|
|
99
|
+
|
|
100
|
+
_fp16_env = os.environ.get("HURI_TTS_FP16")
|
|
101
|
+
if _fp16_env is not None:
|
|
102
|
+
fp16 = _fp16_env.strip().lower() in ("1", "true", "yes", "on")
|
|
103
|
+
else:
|
|
104
|
+
fp16 = torch.cuda.is_available()
|
|
105
|
+
if fp16 and not torch.cuda.is_available():
|
|
106
|
+
print("[TTS] fp16 requested but no CUDA device; falling back to fp32")
|
|
107
|
+
fp16 = False
|
|
108
|
+
|
|
109
|
+
# TensorRT accelerates the flow-matching estimator (often the single
|
|
110
|
+
# biggest TTS speedup) but builds a device-specific engine at startup
|
|
111
|
+
# (~minutes) and needs validation per GPU arch. OFF by default; flip
|
|
112
|
+
# HURI_TTS_TRT=1 to experiment once fp16 is confirmed working.
|
|
113
|
+
load_trt = os.environ.get("HURI_TTS_TRT", "").strip().lower() in (
|
|
114
|
+
"1",
|
|
115
|
+
"true",
|
|
116
|
+
"yes",
|
|
117
|
+
"on",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
print(
|
|
121
|
+
f"[TTS] loading CosyVoice3 (fp16={fp16}, load_trt={load_trt}) "
|
|
122
|
+
f"from {model_path!r}"
|
|
123
|
+
)
|
|
124
|
+
self.model = CosyVoice3(model_dir=model_path, load_trt=load_trt, fp16=fp16)
|
|
125
|
+
self.sample_rate: int = self.model.sample_rate
|
|
126
|
+
|
|
127
|
+
self.prompt_speech = voice_sample_path
|
|
128
|
+
self.prompt_text: str = voice_sample_transcript
|
|
129
|
+
|
|
130
|
+
self._text_queues: dict[str, queue.Queue] = {}
|
|
131
|
+
|
|
132
|
+
# Pay CosyVoice's first-synth costs now, at deploy time, rather than on
|
|
133
|
+
# the first user utterance (see _warmup).
|
|
134
|
+
self._warmup()
|
|
135
|
+
|
|
136
|
+
def _warmup(self) -> None:
|
|
137
|
+
"""Run one throwaway synthesis so the first real utterance is hot.
|
|
138
|
+
|
|
139
|
+
The first CosyVoice call pays one-time costs — CUDA context init,
|
|
140
|
+
cuBLAS/cuDNN algorithm selection, the LM/flow/vocoder first-call
|
|
141
|
+
compiles, and the caching allocator's first growth — that otherwise land
|
|
142
|
+
on the first user utterance and stall it for seconds. Draining a dummy
|
|
143
|
+
synth through the *real* fp16 path (same prompt, stream=True) pays them
|
|
144
|
+
upfront, mirroring the MOV module's warmup.
|
|
145
|
+
|
|
146
|
+
Best-effort: never fatal. The reference sample (voice.wav) is uploaded to
|
|
147
|
+
its PVC *after* the worker starts, so on a brand-new volume it may be
|
|
148
|
+
absent on first boot — warmup is skipped then and runs on the next
|
|
149
|
+
restart once the PVC holds the file.
|
|
150
|
+
"""
|
|
151
|
+
import time
|
|
152
|
+
|
|
153
|
+
if not os.path.isfile(self.prompt_speech):
|
|
154
|
+
print(
|
|
155
|
+
f"[TTS] warmup skipped: reference sample {self.prompt_speech!r} "
|
|
156
|
+
"not present yet (upload voice.wav, then restart to warm)"
|
|
157
|
+
)
|
|
158
|
+
return
|
|
159
|
+
try:
|
|
160
|
+
t0 = time.time()
|
|
161
|
+
|
|
162
|
+
def _dummy_text():
|
|
163
|
+
yield "Hello, this is a warm up."
|
|
164
|
+
|
|
165
|
+
audio_iter = self.model.inference_zero_shot(
|
|
166
|
+
_dummy_text(),
|
|
167
|
+
self.prompt_text,
|
|
168
|
+
self.prompt_speech,
|
|
169
|
+
stream=True,
|
|
170
|
+
)
|
|
171
|
+
chunks = sum(1 for _ in audio_iter)
|
|
172
|
+
print(f"[TTS] warmup done ({chunks} chunks) in {time.time() - t0:.2f}s")
|
|
173
|
+
except Exception as e: # noqa: BLE001 — warmup is an optimisation, never fatal
|
|
174
|
+
print(f"[TTS] WARNING warmup failed: {e!r}")
|
|
175
|
+
|
|
176
|
+
async def get_sample_rate(self) -> int:
|
|
177
|
+
return self.sample_rate
|
|
178
|
+
|
|
179
|
+
async def start_session(self, session_id: str) -> None:
|
|
180
|
+
self._text_queues[session_id] = queue.Queue()
|
|
181
|
+
|
|
182
|
+
async def push_text(self, session_id: str, text: str, end: bool) -> None:
|
|
183
|
+
q = self._text_queues.get(session_id)
|
|
184
|
+
if q is None:
|
|
185
|
+
return
|
|
186
|
+
if text:
|
|
187
|
+
q.put(text)
|
|
188
|
+
if end:
|
|
189
|
+
q.put(_END_TEXT)
|
|
190
|
+
|
|
191
|
+
async def stream_audio(self, session_id: str) -> AsyncGenerator[Audio, None]:
|
|
192
|
+
text_q = self._text_queues[session_id]
|
|
193
|
+
loop = asyncio.get_running_loop()
|
|
194
|
+
chunk_count = 0
|
|
195
|
+
|
|
196
|
+
def text_gen():
|
|
197
|
+
while True:
|
|
198
|
+
item = text_q.get()
|
|
199
|
+
if item is _END_TEXT:
|
|
200
|
+
return
|
|
201
|
+
yield item
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
audio_iter = self.model.inference_zero_shot(
|
|
205
|
+
text_gen(),
|
|
206
|
+
self.prompt_text,
|
|
207
|
+
self.prompt_speech,
|
|
208
|
+
stream=True,
|
|
209
|
+
)
|
|
210
|
+
while True:
|
|
211
|
+
result = await loop.run_in_executor(None, next, audio_iter, _DONE)
|
|
212
|
+
if result is _DONE:
|
|
213
|
+
break
|
|
214
|
+
assert isinstance(result, dict)
|
|
215
|
+
chunk_count += 1
|
|
216
|
+
speech = result["tts_speech"].squeeze(0).numpy().astype(np.float32)
|
|
217
|
+
yield Audio(data=speech, sample_rate=self.sample_rate)
|
|
218
|
+
except Exception:
|
|
219
|
+
traceback.print_exc()
|
|
220
|
+
raise
|
|
221
|
+
finally:
|
|
222
|
+
self._text_queues.pop(session_id, None)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from huri_tts.cosyvoice.handle import CosyVoiceTTSHandle
|
|
2
|
+
from huri_tts.streaming import StreamingTTS
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class CosyVoiceTTS(StreamingTTS):
|
|
6
|
+
"""Text to speech with CosyVoice3: zero-shot voice cloning from a reference
|
|
7
|
+
sample (HURI_VOICE_SAMPLE_PATH + HURI_VOICE_TRANSCRIPT). Needs an NVIDIA GPU
|
|
8
|
+
to run in real time, and the CosyVoice sources on PYTHONPATH
|
|
9
|
+
(install_cosyvoice.sh)."""
|
|
10
|
+
|
|
11
|
+
_handle_cls = CosyVoiceTTSHandle
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
name = "tts"
|
|
2
|
+
summary = "Speaks the reply with Piper (ONNX, CPU, ~30x realtime)"
|
|
3
|
+
input = { topic = "huri.token", event = "huri_events.text:Token" }
|
|
4
|
+
output = { topic = "huri.audio.out", event = "huri_events.audio:Audio" }
|
|
5
|
+
events = ["huri-events>=0.1,<0.2"]
|
|
6
|
+
requires = { gpu = false, actuation = false }
|
|
7
|
+
huri_core = ">=0.4,<0.5"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
name = "tts_cosyvoice"
|
|
2
|
+
summary = "Speaks the reply in a cloned voice with CosyVoice3"
|
|
3
|
+
input = { topic = "huri.token", event = "huri_events.text:Token" }
|
|
4
|
+
output = { topic = "huri.audio.out", event = "huri_events.audio:Audio" }
|
|
5
|
+
events = ["huri-events>=0.1,<0.2"]
|
|
6
|
+
requires = { gpu = true, actuation = false }
|
|
7
|
+
huri_core = ">=0.4,<0.5"
|
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Piper text-to-speech deployment — the portable TTS engine.
|
|
2
|
+
|
|
3
|
+
Why this exists alongside CosyVoice3 (huri_tts.cosyvoice, module `tts_cosyvoice`):
|
|
4
|
+
|
|
5
|
+
CosyVoice gives zero-shot voice cloning but needs torch plus a working
|
|
6
|
+
CUDA/ROCm stack, and on CPU it synthesises at roughly 7x *slower* than
|
|
7
|
+
realtime — unusable in a conversation. It also cannot run on consumer RDNA3
|
|
8
|
+
GPUs at all (the HiFi-GAN vocoder faults inside a MIOpen convolution), so on
|
|
9
|
+
an AMD machine there is no fast path.
|
|
10
|
+
|
|
11
|
+
Piper is ONNX-only: no torch, no GPU, a 61 MB voice model, and measured at
|
|
12
|
+
~0.03x realtime on a Ryzen 7840HS — about 30x faster than it speaks, with the
|
|
13
|
+
first chunk in ~30 ms. That makes it the engine that works on *any* machine,
|
|
14
|
+
which is why it is the default. CosyVoice (huri_tts.cosyvoice) is the
|
|
15
|
+
other engine, for when an NVIDIA GPU is present and the cloned voice matters
|
|
16
|
+
more than latency: a separate module, `tts_cosyvoice`, wired in its place.
|
|
17
|
+
|
|
18
|
+
The public surface is the one huri_tts.streaming.StreamingTTS drives —
|
|
19
|
+
``get_sample_rate`` / ``start_session`` / ``push_text`` / ``stream_audio``. The
|
|
20
|
+
deployment keeps the name ``TTSHandle``, so existing ``deployments:`` blocks
|
|
21
|
+
still apply to the default engine.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import traceback
|
|
28
|
+
from typing import AsyncGenerator, Dict, List
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from huri_events.audio import Audio
|
|
32
|
+
from ray import serve
|
|
33
|
+
|
|
34
|
+
_VOICE_PATH = os.environ.get(
|
|
35
|
+
"HURI_PIPER_VOICE", "assets/models/piper/en_US-lessac-medium.onnx"
|
|
36
|
+
)
|
|
37
|
+
# Sentence-ish boundaries. Piper synthesises a whole utterance at a time, so we
|
|
38
|
+
# flush on these to start speaking before the LLM has finished writing.
|
|
39
|
+
_BOUNDARY = re.compile(r"[.!?…]['\"\)\]]?\s")
|
|
40
|
+
# Don't flush on a boundary this early — "Dr." or "1." would cut the phrase into
|
|
41
|
+
# unnatural fragments, and Piper's prosody is better with more context.
|
|
42
|
+
_MIN_FLUSH_CHARS = 24
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@serve.deployment(name="TTSHandle")
|
|
46
|
+
class PiperTTSHandle:
|
|
47
|
+
"""Piper wrapper with per-session incremental synthesis.
|
|
48
|
+
|
|
49
|
+
Stateless across sessions apart from the per-session text buffer: the ONNX
|
|
50
|
+
session is shared and thread-safe for inference, so one replica serves every
|
|
51
|
+
client.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
voice_path: str = _VOICE_PATH,
|
|
57
|
+
length_scale: float | None = None,
|
|
58
|
+
volume: float | None = None,
|
|
59
|
+
):
|
|
60
|
+
from piper import PiperVoice
|
|
61
|
+
|
|
62
|
+
if not os.path.isfile(voice_path):
|
|
63
|
+
raise RuntimeError(
|
|
64
|
+
f"[PiperTTS] voice model not found: {voice_path!r}. "
|
|
65
|
+
"Download one with: python -m piper.download_voices "
|
|
66
|
+
"--download-dir assets/models/piper en_US-lessac-medium"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self._voice = PiperVoice.load(voice_path)
|
|
70
|
+
self._syn_config = self._build_syn_config(length_scale, volume)
|
|
71
|
+
# Probe the real output rate rather than trusting a constant: it differs
|
|
72
|
+
# per voice (22.05 kHz for -medium, 16 kHz for some -low voices) and the
|
|
73
|
+
# client resamples against whatever we report.
|
|
74
|
+
self._sample_rate = int(self._voice.config.sample_rate)
|
|
75
|
+
self._buffers: Dict[str, List[str]] = {}
|
|
76
|
+
self._queues: Dict[str, asyncio.Queue] = {}
|
|
77
|
+
print(
|
|
78
|
+
f"[PiperTTS] loaded {os.path.basename(voice_path)} "
|
|
79
|
+
f"@ {self._sample_rate}Hz (onnxruntime, CPU)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _build_syn_config(length_scale: float | None, volume: float | None):
|
|
84
|
+
"""None when nothing is overridden, so Piper uses the voice's own defaults."""
|
|
85
|
+
|
|
86
|
+
from piper import SynthesisConfig
|
|
87
|
+
|
|
88
|
+
if length_scale is None and volume is None:
|
|
89
|
+
return None
|
|
90
|
+
# Fields set individually rather than via **kwargs: SynthesisConfig mixes
|
|
91
|
+
# int/bool/float fields, so a kwargs dict cannot be type-checked.
|
|
92
|
+
syn_config = SynthesisConfig()
|
|
93
|
+
if length_scale is not None:
|
|
94
|
+
syn_config.length_scale = length_scale
|
|
95
|
+
if volume is not None:
|
|
96
|
+
syn_config.volume = volume
|
|
97
|
+
return syn_config
|
|
98
|
+
|
|
99
|
+
async def get_sample_rate(self) -> int:
|
|
100
|
+
return self._sample_rate
|
|
101
|
+
|
|
102
|
+
async def start_session(self, session_id: str) -> None:
|
|
103
|
+
self._buffers[session_id] = []
|
|
104
|
+
self._queues[session_id] = asyncio.Queue()
|
|
105
|
+
|
|
106
|
+
async def push_text(self, session_id: str, text: str, end: bool) -> None:
|
|
107
|
+
"""Accumulate LLM tokens, flushing complete phrases for synthesis.
|
|
108
|
+
|
|
109
|
+
Unlike CosyVoice there is no bistream mode to push tokens into, so the
|
|
110
|
+
phrase boundary is ours to choose. Flushing per sentence keeps the
|
|
111
|
+
time-to-first-audio low while still giving Piper enough context for
|
|
112
|
+
sensible prosody.
|
|
113
|
+
"""
|
|
114
|
+
buf = self._buffers.setdefault(session_id, [])
|
|
115
|
+
queue = self._queues.setdefault(session_id, asyncio.Queue())
|
|
116
|
+
if text:
|
|
117
|
+
buf.append(text)
|
|
118
|
+
|
|
119
|
+
joined = "".join(buf)
|
|
120
|
+
if not end:
|
|
121
|
+
# Only flush on a boundary that is followed by whitespace, so a
|
|
122
|
+
# decimal point or an abbreviation does not split the phrase.
|
|
123
|
+
match = None
|
|
124
|
+
for m in _BOUNDARY.finditer(joined):
|
|
125
|
+
match = m
|
|
126
|
+
if match is None or match.end() < _MIN_FLUSH_CHARS:
|
|
127
|
+
return
|
|
128
|
+
phrase, remainder = joined[: match.end()], joined[match.end() :]
|
|
129
|
+
buf.clear()
|
|
130
|
+
if remainder:
|
|
131
|
+
buf.append(remainder)
|
|
132
|
+
else:
|
|
133
|
+
phrase, _ = joined, ""
|
|
134
|
+
buf.clear()
|
|
135
|
+
|
|
136
|
+
phrase = phrase.strip()
|
|
137
|
+
if phrase:
|
|
138
|
+
await queue.put(phrase)
|
|
139
|
+
if end:
|
|
140
|
+
await queue.put(None) # end-of-utterance sentinel
|
|
141
|
+
|
|
142
|
+
async def stream_audio(self, session_id: str) -> AsyncGenerator[Audio, None]:
|
|
143
|
+
queue = self._queues.setdefault(session_id, asyncio.Queue())
|
|
144
|
+
loop = asyncio.get_running_loop()
|
|
145
|
+
pts = 0.0
|
|
146
|
+
try:
|
|
147
|
+
while True:
|
|
148
|
+
phrase = await queue.get()
|
|
149
|
+
if phrase is None:
|
|
150
|
+
# Zero-length end marker, matching TTSHandle's contract.
|
|
151
|
+
yield Audio(
|
|
152
|
+
data=np.zeros(0, dtype=np.float32),
|
|
153
|
+
sample_rate=self._sample_rate,
|
|
154
|
+
end=True,
|
|
155
|
+
pts=pts,
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
# Synthesis is CPU-bound C++ inside onnxruntime; run it off the
|
|
159
|
+
# event loop so the replica keeps serving other sessions.
|
|
160
|
+
chunks = await loop.run_in_executor(None, self._synthesize, phrase)
|
|
161
|
+
for samples in chunks:
|
|
162
|
+
if samples.size == 0:
|
|
163
|
+
continue
|
|
164
|
+
yield Audio(
|
|
165
|
+
data=samples,
|
|
166
|
+
sample_rate=self._sample_rate,
|
|
167
|
+
end=False,
|
|
168
|
+
pts=pts,
|
|
169
|
+
)
|
|
170
|
+
pts += samples.size / self._sample_rate
|
|
171
|
+
except Exception:
|
|
172
|
+
traceback.print_exc()
|
|
173
|
+
raise
|
|
174
|
+
finally:
|
|
175
|
+
self._buffers.pop(session_id, None)
|
|
176
|
+
self._queues.pop(session_id, None)
|
|
177
|
+
|
|
178
|
+
def _synthesize(self, phrase: str) -> List[np.ndarray]:
|
|
179
|
+
out: List[np.ndarray] = []
|
|
180
|
+
for chunk in self._voice.synthesize(phrase, syn_config=self._syn_config):
|
|
181
|
+
out.append(np.asarray(chunk.audio_float_array, dtype=np.float32))
|
|
182
|
+
return out
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from huri_tts.piper.handle import PiperTTSHandle
|
|
2
|
+
from huri_tts.streaming import StreamingTTS
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PiperTTS(StreamingTTS):
|
|
6
|
+
"""Text to speech with Piper (ONNX): CPU-only, ~30x faster than realtime.
|
|
7
|
+
|
|
8
|
+
The default engine: it runs on any machine. See handle.py for why.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
_handle_cls = PiperTTSHandle
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""The engine-agnostic half of HuRI's text-to-speech modules."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from huri_events.audio import Audio
|
|
9
|
+
from huri_events.text import Token
|
|
10
|
+
|
|
11
|
+
from huri_core.core.module import ModuleWithHandle
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from ray.serve import handle
|
|
15
|
+
|
|
16
|
+
_END_AUDIO = object() # sentinel pushed into the audio queue when synthesis ends
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StreamingTTS(ModuleWithHandle):
|
|
20
|
+
"""Streaming TTS module — bistream tokens-in / audio-out, engine-agnostic.
|
|
21
|
+
|
|
22
|
+
Opens one synthesis session per utterance (delimited by `token.end`). Each
|
|
23
|
+
incoming token is pushed straight into the model's text generator so audio
|
|
24
|
+
starts coming back before the LLM has finished producing the response.
|
|
25
|
+
No clause buffering on our side — CosyVoice's frontend handles segmentation
|
|
26
|
+
and stitches LM calls together across the whole utterance.
|
|
27
|
+
|
|
28
|
+
It drives any deployment exposing ``get_sample_rate()``,
|
|
29
|
+
``start_session(sid)``, ``push_text(sid, text, end)`` and
|
|
30
|
+
``stream_audio(sid)`` (an async generator of Audio); an engine package
|
|
31
|
+
subclasses it and sets ``_handle_cls`` to its deployment.
|
|
32
|
+
|
|
33
|
+
input: huri.token (Token)
|
|
34
|
+
output: huri.audio.out (Audio)
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
input_type = "huri.token"
|
|
38
|
+
output_type = "huri.audio.out"
|
|
39
|
+
input_event = Token
|
|
40
|
+
output_event = Audio
|
|
41
|
+
|
|
42
|
+
def __init__(self, _handle: "handle.DeploymentHandle"):
|
|
43
|
+
super().__init__(_handle)
|
|
44
|
+
self._session_id: str | None = None
|
|
45
|
+
self._audio_q: asyncio.Queue | None = None
|
|
46
|
+
self._stream_task: asyncio.Task | None = None
|
|
47
|
+
# The EventGraph fans each token out as its own concurrent process()
|
|
48
|
+
# task on this shared instance. This lock serialises session setup and
|
|
49
|
+
# text pushes so tokens reach CosyVoice's text queue in arrival order,
|
|
50
|
+
# exactly once. asyncio.Lock wakes waiters FIFO and tokens are created
|
|
51
|
+
# in order, so order is preserved — crucially the end-of-utterance token
|
|
52
|
+
# can no longer overtake a content token (which would truncate synthesis
|
|
53
|
+
# and silently drop trailing words).
|
|
54
|
+
self._push_lock = asyncio.Lock()
|
|
55
|
+
|
|
56
|
+
async def process(self, token: Token) -> AsyncGenerator[Audio, None]:
|
|
57
|
+
# Acquire BEFORE any await so lock-acquisition order matches token order.
|
|
58
|
+
# Setup + push happen under the lock; only the first token of an
|
|
59
|
+
# utterance goes on to drain/yield audio (outside the lock, so pushes of
|
|
60
|
+
# later tokens are never blocked by the long-running drain).
|
|
61
|
+
async with self._push_lock:
|
|
62
|
+
is_first = self._session_id is None
|
|
63
|
+
if is_first:
|
|
64
|
+
self._session_id = str(uuid.uuid4())
|
|
65
|
+
self._audio_q = asyncio.Queue()
|
|
66
|
+
print(
|
|
67
|
+
f"[TTS-client] [{self._session_id}] opening new utterance session"
|
|
68
|
+
)
|
|
69
|
+
await self._handle.start_session.remote(self._session_id)
|
|
70
|
+
self._stream_task = asyncio.create_task(
|
|
71
|
+
self._drain_audio(self._session_id, self._audio_q)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
sid = self._session_id
|
|
75
|
+
audio_q = self._audio_q
|
|
76
|
+
stream_task = self._stream_task
|
|
77
|
+
print(f"[TTS-client] [{sid}] push token: {token.text!r} (end={token.end})")
|
|
78
|
+
await self._handle.push_text.remote(sid, token.text, token.end)
|
|
79
|
+
|
|
80
|
+
if not is_first:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
assert audio_q is not None and stream_task is not None
|
|
84
|
+
try:
|
|
85
|
+
count = 0
|
|
86
|
+
while True:
|
|
87
|
+
item = await audio_q.get()
|
|
88
|
+
if item is _END_AUDIO:
|
|
89
|
+
break
|
|
90
|
+
count += 1
|
|
91
|
+
print(f"[TTS-client] [{sid}] yield chunk #{count}")
|
|
92
|
+
yield item
|
|
93
|
+
await stream_task
|
|
94
|
+
print(f"[TTS-client] [{sid}] utterance complete ({count} chunks)")
|
|
95
|
+
|
|
96
|
+
sample_rate = await self._handle.get_sample_rate.remote()
|
|
97
|
+
yield Audio(
|
|
98
|
+
data=np.array([], dtype=np.float32), sample_rate=sample_rate, end=True
|
|
99
|
+
)
|
|
100
|
+
finally:
|
|
101
|
+
async with self._push_lock:
|
|
102
|
+
self._session_id = None
|
|
103
|
+
self._audio_q = None
|
|
104
|
+
self._stream_task = None
|
|
105
|
+
|
|
106
|
+
async def _drain_audio(self, session_id: str, audio_q: asyncio.Queue) -> None:
|
|
107
|
+
try:
|
|
108
|
+
# Any: the Ray response type differs with/without ray installed
|
|
109
|
+
# (CI lints without it), so a `type: ignore` would flip between
|
|
110
|
+
# needed and unused.
|
|
111
|
+
response: Any = self._handle.options(stream=True).stream_audio.remote(
|
|
112
|
+
session_id
|
|
113
|
+
)
|
|
114
|
+
count = 0
|
|
115
|
+
pts = 0.0
|
|
116
|
+
async for audio in response:
|
|
117
|
+
count += 1
|
|
118
|
+
audio.pts = pts
|
|
119
|
+
pts += audio.data.shape[0] / audio.sample_rate
|
|
120
|
+
print(
|
|
121
|
+
f"[TTS-client] [{session_id}] drain received chunk #{count} "
|
|
122
|
+
f"pts={audio.pts:.3f}s next={pts:.3f}s",
|
|
123
|
+
)
|
|
124
|
+
await audio_q.put(audio)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
print(f"[TTS-client] [{session_id}] drain task FAILED: {e!r}")
|
|
127
|
+
raise
|
|
128
|
+
finally:
|
|
129
|
+
await audio_q.put(_END_AUDIO)
|
|
130
|
+
print(f"[TTS-client] [{session_id}] drain task finished")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64,<82"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "huri-module-tts"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HuRI modules: text to speech with Piper (CPU, any machine) or CosyVoice3 (voice cloning, NVIDIA GPU)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
# The streaming logic both engines share. No engine library here: each comes
|
|
12
|
+
# with its extra, so a host that runs one engine never installs the other's
|
|
13
|
+
# stack (piper's onnxruntime and CosyVoice's onnxruntime-gpu do not mix).
|
|
14
|
+
dependencies = [
|
|
15
|
+
"huri-core[server]>=0.4,<0.5",
|
|
16
|
+
"huri-events>=0.1,<0.2",
|
|
17
|
+
"numpy>=1.26,<2",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
piper = ["piper-tts>=1.8,<1.9"]
|
|
22
|
+
# CosyVoice itself is not pip-installable (install_cosyvoice.sh); its hardware
|
|
23
|
+
# pins are requirements-<variant>.tts_cosyvoice.txt.
|
|
24
|
+
cosyvoice = ["torch", "torchaudio"]
|
|
25
|
+
|
|
26
|
+
[project.entry-points."huri.modules"]
|
|
27
|
+
tts = "huri_tts.piper.module:PiperTTS"
|
|
28
|
+
tts_cosyvoice = "huri_tts.cosyvoice.module:CosyVoiceTTS"
|
|
29
|
+
|
|
30
|
+
# The extra each module needs: read by huri-modules' tooling
|
|
31
|
+
# (scripts/module_requirements.py, check_registry.py against registry.toml's
|
|
32
|
+
# `extra`), not by huri-core.
|
|
33
|
+
[tool.huri-modules.extras]
|
|
34
|
+
tts = "piper"
|
|
35
|
+
tts_cosyvoice = "cosyvoice"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
include = ["huri_tts*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.package-data]
|
|
41
|
+
huri_tts = ["manifests/*/huri_module.toml"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""What can be checked of CosyVoice without its sources and model."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from huri_tts.cosyvoice.handle import (
|
|
6
|
+
CosyVoiceTTSHandle,
|
|
7
|
+
_add_cosyvoice_to_path,
|
|
8
|
+
_normalize_transcript,
|
|
9
|
+
)
|
|
10
|
+
from huri_tts.cosyvoice.module import CosyVoiceTTS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_a_bare_transcript_is_placed_after_the_prompt_marker():
|
|
14
|
+
# Before the marker the LM reads it as an instruction and may speak it.
|
|
15
|
+
bare = "Instinct creates its own oppressors."
|
|
16
|
+
assert _normalize_transcript(bare) == (
|
|
17
|
+
f"You are a helpful assistant.<|endofprompt|>{bare}"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_an_explicit_instruction_is_kept():
|
|
22
|
+
raw = "Speak softly.<|endofprompt|>Hello."
|
|
23
|
+
assert _normalize_transcript(raw) == raw
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_the_module_binds_its_own_deployment():
|
|
27
|
+
assert CosyVoiceTTS._handle_cls is CosyVoiceTTSHandle
|
|
28
|
+
# Not "TTSHandle": both engines can then run in one Serve app.
|
|
29
|
+
assert CosyVoiceTTSHandle.name == "CosyVoiceTTSHandle"
|
|
30
|
+
assert (CosyVoiceTTS.input_type, CosyVoiceTTS.output_type) == (
|
|
31
|
+
"huri.token",
|
|
32
|
+
"huri.audio.out",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_cosy_dir_puts_the_checkout_and_matcha_on_the_path(tmp_path, monkeypatch):
|
|
37
|
+
# The Serve configs set HURI_COSY_DIR only, no per-host PYTHONPATH.
|
|
38
|
+
matcha = tmp_path / "third_party" / "Matcha-TTS"
|
|
39
|
+
matcha.mkdir(parents=True)
|
|
40
|
+
monkeypatch.setattr(sys, "path", list(sys.path))
|
|
41
|
+
_add_cosyvoice_to_path(str(tmp_path))
|
|
42
|
+
_add_cosyvoice_to_path(str(tmp_path))
|
|
43
|
+
assert sys.path.count(str(tmp_path)) == 1
|
|
44
|
+
assert sys.path.count(str(matcha)) == 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Piper's deployment logic, with the ONNX voice replaced by a fake."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from types import SimpleNamespace
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from huri_events.text import Token
|
|
8
|
+
from huri_tts.piper.handle import PiperTTSHandle
|
|
9
|
+
from huri_tts.piper.module import PiperTTS
|
|
10
|
+
|
|
11
|
+
from huri_core.testing import LocalHandle, ModuleHarness
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeVoice:
|
|
15
|
+
"""10 samples per character, in two chunks per phrase."""
|
|
16
|
+
|
|
17
|
+
def synthesize(self, phrase, syn_config=None):
|
|
18
|
+
half = len(phrase) * 5
|
|
19
|
+
for _ in range(2):
|
|
20
|
+
yield SimpleNamespace(audio_float_array=np.ones(half, dtype=np.float32))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def handle():
|
|
24
|
+
deployment = PiperTTSHandle.func_or_class.__new__(PiperTTSHandle.func_or_class)
|
|
25
|
+
deployment._voice = FakeVoice()
|
|
26
|
+
deployment._syn_config = None
|
|
27
|
+
deployment._sample_rate = 22050
|
|
28
|
+
deployment._buffers, deployment._queues = {}, {}
|
|
29
|
+
return deployment
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def phrases(h, tokens):
|
|
33
|
+
await h.start_session("s")
|
|
34
|
+
for text, end in tokens:
|
|
35
|
+
await h.push_text("s", text, end)
|
|
36
|
+
queue, out = h._queues["s"], []
|
|
37
|
+
while not queue.empty():
|
|
38
|
+
out.append(await queue.get())
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_tokens_are_flushed_by_sentence_once_long_enough():
|
|
43
|
+
tokens = [
|
|
44
|
+
("Dr. Smith is here. ", False),
|
|
45
|
+
("He says hello to everyone! ", False),
|
|
46
|
+
("Bye", False),
|
|
47
|
+
("", True),
|
|
48
|
+
]
|
|
49
|
+
assert asyncio.run(phrases(handle(), tokens)) == [
|
|
50
|
+
"Dr. Smith is here. He says hello to everyone!",
|
|
51
|
+
"Bye",
|
|
52
|
+
None, # end of utterance
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_streamed_audio_is_timed_and_ends_with_a_marker():
|
|
57
|
+
async def run():
|
|
58
|
+
h = handle()
|
|
59
|
+
await h.start_session("s")
|
|
60
|
+
await h.push_text("s", "Hello there.", True)
|
|
61
|
+
return [a async for a in h.stream_audio("s")]
|
|
62
|
+
|
|
63
|
+
audio = asyncio.run(run())
|
|
64
|
+
assert [a.data.size for a in audio] == [60, 60, 0]
|
|
65
|
+
assert [a.pts for a in audio] == [0.0, 60 / 22050, 120 / 22050]
|
|
66
|
+
assert audio[-1].end and not audio[0].end
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_the_module_drives_the_deployment_end_to_end():
|
|
70
|
+
harness = ModuleHarness(PiperTTS, handle=LocalHandle(handle()))
|
|
71
|
+
|
|
72
|
+
async def speak():
|
|
73
|
+
tokens = [Token("Hello there, this is HuRI. ", False), Token("", True)]
|
|
74
|
+
return await asyncio.gather(*(harness.feed(t) for t in tokens))
|
|
75
|
+
|
|
76
|
+
first, rest = asyncio.run(speak())
|
|
77
|
+
assert rest == [] and first[-1].end
|
|
78
|
+
assert sum(a.data.size for a in first) == len("Hello there, this is HuRI.") * 10
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""StreamingTTS on a fake engine, fed the way the bus feeds it: one concurrent
|
|
2
|
+
process() call per token."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from huri_events.audio import Audio
|
|
8
|
+
from huri_events.text import Token
|
|
9
|
+
from huri_tts.streaming import StreamingTTS
|
|
10
|
+
|
|
11
|
+
from huri_core.testing import LocalHandle, ModuleHarness
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeEngine:
|
|
15
|
+
"""The four methods StreamingTTS drives; 10 samples per pushed character."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.sessions = {}
|
|
19
|
+
|
|
20
|
+
async def get_sample_rate(self):
|
|
21
|
+
return 16000
|
|
22
|
+
|
|
23
|
+
async def start_session(self, sid):
|
|
24
|
+
self.sessions[sid] = {"texts": [], "done": asyncio.Event()}
|
|
25
|
+
|
|
26
|
+
async def push_text(self, sid, text, end):
|
|
27
|
+
await asyncio.sleep(0) # let concurrent pushes interleave if they could
|
|
28
|
+
session = self.sessions[sid]
|
|
29
|
+
session["texts"].append(text)
|
|
30
|
+
if end:
|
|
31
|
+
session["done"].set()
|
|
32
|
+
|
|
33
|
+
async def stream_audio(self, sid):
|
|
34
|
+
session = self.sessions[sid]
|
|
35
|
+
await session["done"].wait()
|
|
36
|
+
for text in session["texts"]:
|
|
37
|
+
if text:
|
|
38
|
+
yield Audio(np.ones(len(text) * 10, dtype=np.float32), 16000)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Engine(StreamingTTS):
|
|
42
|
+
_handle_cls = FakeEngine
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def speak(harness, tokens):
|
|
46
|
+
return await asyncio.gather(*(harness.feed(t) for t in tokens))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_an_utterance_streams_in_token_order_and_ends_with_a_marker():
|
|
50
|
+
engine = FakeEngine()
|
|
51
|
+
harness = ModuleHarness(Engine, handle=LocalHandle(engine))
|
|
52
|
+
tokens = [Token("Hello", False), Token(" there", False), Token("", True)]
|
|
53
|
+
first, *rest = asyncio.run(speak(harness, tokens))
|
|
54
|
+
|
|
55
|
+
assert rest == [[], []] # only the utterance's first call yields audio
|
|
56
|
+
(session,) = engine.sessions.values()
|
|
57
|
+
assert session["texts"] == ["Hello", " there", ""]
|
|
58
|
+
assert [chunk.data.size for chunk in first] == [50, 60, 0]
|
|
59
|
+
assert [chunk.pts for chunk in first[:2]] == [0.0, 50 / 16000]
|
|
60
|
+
assert first[-1].end and first[-1].sample_rate == 16000
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_each_utterance_gets_its_own_session():
|
|
64
|
+
engine = FakeEngine()
|
|
65
|
+
harness = ModuleHarness(Engine, handle=LocalHandle(engine))
|
|
66
|
+
|
|
67
|
+
async def two_utterances(): # one event loop, like the server
|
|
68
|
+
await speak(harness, [Token("One.", False), Token("", True)])
|
|
69
|
+
await speak(harness, [Token("Two.", False), Token("", True)])
|
|
70
|
+
|
|
71
|
+
asyncio.run(two_utterances())
|
|
72
|
+
assert [s["texts"] for s in engine.sessions.values()] == [
|
|
73
|
+
["One.", ""],
|
|
74
|
+
["Two.", ""],
|
|
75
|
+
]
|