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.
@@ -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,3 @@
1
+ [huri.modules]
2
+ tts = huri_tts.piper.module:PiperTTS
3
+ tts_cosyvoice = huri_tts.cosyvoice.module:CosyVoiceTTS
@@ -0,0 +1,10 @@
1
+ huri-core[server]<0.5,>=0.4
2
+ huri-events<0.2,>=0.1
3
+ numpy<2,>=1.26
4
+
5
+ [cosyvoice]
6
+ torch
7
+ torchaudio
8
+
9
+ [piper]
10
+ piper-tts<1.9,>=1.8
@@ -0,0 +1,4 @@
1
+ """HuRI's text-to-speech modules: `tts` (Piper, huri_tts.piper) and
2
+ `tts_cosyvoice` (CosyVoice3, huri_tts.cosyvoice), both built on
3
+ huri_tts.streaming.StreamingTTS. Each engine's dependencies are an extra of
4
+ huri-module-tts (`piper`, `cosyvoice`)."""
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]