piper-tts-server 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.
- piper_tts_server-0.1.0/LICENSE +23 -0
- piper_tts_server-0.1.0/PKG-INFO +132 -0
- piper_tts_server-0.1.0/README.md +96 -0
- piper_tts_server-0.1.0/piper_tts_server/__init__.py +16 -0
- piper_tts_server-0.1.0/piper_tts_server/__main__.py +4 -0
- piper_tts_server-0.1.0/piper_tts_server/config.py +44 -0
- piper_tts_server-0.1.0/piper_tts_server/pacing.py +71 -0
- piper_tts_server-0.1.0/piper_tts_server/sanitize.py +56 -0
- piper_tts_server-0.1.0/piper_tts_server/server.py +169 -0
- piper_tts_server-0.1.0/piper_tts_server/tts.py +266 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/PKG-INFO +132 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/SOURCES.txt +19 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/dependency_links.txt +1 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/entry_points.txt +2 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/requires.txt +19 -0
- piper_tts_server-0.1.0/piper_tts_server.egg-info/top_level.txt +1 -0
- piper_tts_server-0.1.0/pyproject.toml +46 -0
- piper_tts_server-0.1.0/setup.cfg +4 -0
- piper_tts_server-0.1.0/tests/test_dsp.py +32 -0
- piper_tts_server-0.1.0/tests/test_pacing.py +44 -0
- piper_tts_server-0.1.0/tests/test_sanitize.py +33 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ICT Innovations (https://www.ictinnovations.com), Tahir Almas
|
|
4
|
+
|
|
5
|
+
Portions derived from ICTContact (https://www.ictcontact.com).
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piper-tts-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Paced, streaming Piper (+ElevenLabs) TTS microservice for real-time voice pipelines (Asterisk AudioSocket, RTP, WebSocket).
|
|
5
|
+
Author-email: Tahir Almas <tahir@ictinnovations.com>
|
|
6
|
+
Maintainer-email: ICT Innovations <info@ictinnovations.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/ictinnovations/piper-tts-server
|
|
9
|
+
Project-URL: Repository, https://github.com/ictinnovations/piper-tts-server
|
|
10
|
+
Project-URL: Derived From, https://www.ictcontact.com
|
|
11
|
+
Keywords: piper,tts,text-to-speech,asterisk,audiosocket,voice,streaming,realtime,voip
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy>=1.21
|
|
22
|
+
Provides-Extra: piper
|
|
23
|
+
Requires-Dist: piper-tts>=1.2; extra == "piper"
|
|
24
|
+
Provides-Extra: server
|
|
25
|
+
Requires-Dist: aiohttp>=3.8; extra == "server"
|
|
26
|
+
Provides-Extra: elevenlabs
|
|
27
|
+
Requires-Dist: httpx>=0.24; extra == "elevenlabs"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: piper-tts>=1.2; extra == "all"
|
|
30
|
+
Requires-Dist: aiohttp>=3.8; extra == "all"
|
|
31
|
+
Requires-Dist: httpx>=0.24; extra == "all"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
34
|
+
Requires-Dist: aiohttp>=3.8; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# piper-tts-server
|
|
38
|
+
|
|
39
|
+
**Paced, streaming [Piper](https://github.com/OHF-Voice/piper1-gpl) TTS for real-time voice pipelines** — Asterisk AudioSocket, RTP, or a browser WebSocket. Optional ElevenLabs cloud voices with automatic local fallback.
|
|
40
|
+
|
|
41
|
+
[](https://github.com/ictinnovations/piper-tts-server/actions)
|
|
42
|
+
[](https://pypi.org/project/piper-tts-server/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
If you have tried to bolt Piper onto a phone call and hit **dead air before the greeting**, **the first few words clipped**, or **the caller only hearing the tail of each sentence** — this library is the fix. It packages the three things that make Piper work in a real-time loop:
|
|
46
|
+
|
|
47
|
+
1. **Process-wide voice cache.** `PiperVoice.load()` costs 2.5–5.5 s. Loading per request means that much dead air before every utterance. Voices load once and are reused.
|
|
48
|
+
2. **A single synthesis lock.** Piper phonemizes via espeak-ng, whose C API is **not thread-safe**. All synthesis is serialized behind one lock so concurrent calls don't corrupt each other.
|
|
49
|
+
3. **Deadline-based frame pacing.** Real-time transports forward each frame the instant it arrives; bursting a whole utterance overruns the far end's jitter buffer. `PacedWriter` releases exactly one frame per interval on a monotonic deadline — and **re-clamps every frame** so a synthesis stall never turns into a catch-up burst that clips the next words.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "piper-tts-server[all]" # library + server + piper + elevenlabs
|
|
55
|
+
# or minimal:
|
|
56
|
+
pip install piper-tts-server # library core (numpy only)
|
|
57
|
+
pip install "piper-tts-server[piper,server]"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Download a Piper voice (`.onnx` + `.onnx.json`) into a `voices/` directory — see the [Piper voices list](https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/voices.md).
|
|
61
|
+
|
|
62
|
+
## Run the service
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
PIPER_TTS_VOICES_DIR=voices PIPER_TTS_DEFAULT_VOICE=en_US-amy-medium \
|
|
66
|
+
piper-tts-server # listens on 127.0.0.1:8080
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# one-shot WAV
|
|
71
|
+
curl -X POST localhost:8080/synthesize \
|
|
72
|
+
-H 'Content-Type: application/json' \
|
|
73
|
+
-d '{"text":"Hello from Piper."}' --output hello.wav
|
|
74
|
+
|
|
75
|
+
# paced streaming slin16 frames (feed straight to AudioSocket/RTP)
|
|
76
|
+
curl -N -X POST localhost:8080/synthesize/stream \
|
|
77
|
+
-H 'Content-Type: application/json' \
|
|
78
|
+
-d '{"text":"Your call is being connected.","paced":true}' --output frames.slin
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Endpoint | Purpose |
|
|
82
|
+
|----------|---------|
|
|
83
|
+
| `GET /health` | status + cached voices |
|
|
84
|
+
| `POST /warm` `{voice_id}` | preload a voice into the cache |
|
|
85
|
+
| `POST /synthesize` `{text, voice_id, provider, format}` | one-shot `wav` (default) or raw `pcm` |
|
|
86
|
+
| `POST /synthesize/stream` `{text, …, paced}` | chunked frames; `paced:true` = deadline-clocked |
|
|
87
|
+
|
|
88
|
+
## Use as a library (no HTTP in the media path)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from piper_tts_server import StreamingTTS, PacedWriter, tts_sanitize
|
|
92
|
+
|
|
93
|
+
tts = StreamingTTS(voices_dir="voices", default_voice_path="voices/en_US-amy-medium.onnx")
|
|
94
|
+
await tts.start()
|
|
95
|
+
|
|
96
|
+
writer = PacedWriter(my_audiosocket_write, frame_ms=20) # sink: async fn(bytes)
|
|
97
|
+
async for frame in tts.synthesise(tts_sanitize(text)):
|
|
98
|
+
await writer.write(frame) # 320 bytes / 20 ms, paced
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [`examples/asterisk_audiosocket.md`](examples/asterisk_audiosocket.md) for the full AudioSocket wiring and streaming-LLM sentence chunking.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
All via env (or `ServerConfig`): `PIPER_TTS_HOST`, `PIPER_TTS_PORT`, `PIPER_TTS_VOICES_DIR`, `PIPER_TTS_DEFAULT_VOICE`, `PIPER_TTS_PROVIDER` (`piper`|`elevenlabs`), `PIPER_TTS_ELEVENLABS_API_KEY`, `PIPER_TTS_SAMPLE_RATE` (default `8000`), `PIPER_TTS_FRAME_MS` (default `20`), `PIPER_TTS_WARM_ON_START`.
|
|
106
|
+
|
|
107
|
+
Default output is **8 kHz slin16 / 20 ms frames** (telephony). Set `PIPER_TTS_SAMPLE_RATE=16000` for wideband.
|
|
108
|
+
|
|
109
|
+
### ElevenLabs with local fallback
|
|
110
|
+
|
|
111
|
+
Set `PIPER_TTS_PROVIDER=elevenlabs` + `PIPER_TTS_ELEVENLABS_API_KEY` and pass a `voice_id`. Any failure falls back to the local Piper default voice so the stream never goes silent; a `401/403` disables further cloud attempts for that engine.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install "piper-tts-server[dev]"
|
|
117
|
+
pytest # pure-DSP, sanitize, and pacing tests run without Piper installed
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Licensing
|
|
121
|
+
|
|
122
|
+
This package is **MIT licensed** and contains no Piper source code. Piper is an optional extra, imported lazily only when the local engine is used — the core library installs with numpy alone, and the test suite runs without Piper present.
|
|
123
|
+
|
|
124
|
+
Piper itself is a separate project under a different licence. The maintained release ([`OHF-Voice/piper1-gpl`](https://github.com/OHF-Voice/piper1-gpl), PyPI [`piper-tts`](https://pypi.org/project/piper-tts/)) is **GPL-3.0-or-later**; the older MIT `rhasspy/piper` is archived. If you install the `[piper]` or `[all]` extra, the resulting combined installation is subject to GPL-3.0 terms. The ElevenLabs provider, and the pacing and sanitising helpers on their own, pull in no GPL code.
|
|
125
|
+
|
|
126
|
+
**Not affiliated with, or endorsed by, the Open Home Foundation or the Piper project.** This is an independent server and library that drives Piper as one of several backends.
|
|
127
|
+
|
|
128
|
+
## Provenance & credits
|
|
129
|
+
|
|
130
|
+
Extracted and generalized from the TTS layer of **[ICTContact](https://www.ictcontact.com)**'s AI Voice Agent, by **[ICT Innovations](https://www.ictinnovations.com)** / **[ICT Vision](https://ict.vision)**. Author: **Tahir Almas**. Companion project: [`asterisk-ai-voice-agent`](https://github.com/ictinnovations/asterisk-ai-voice-agent).
|
|
131
|
+
|
|
132
|
+
MIT licensed — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# piper-tts-server
|
|
2
|
+
|
|
3
|
+
**Paced, streaming [Piper](https://github.com/OHF-Voice/piper1-gpl) TTS for real-time voice pipelines** — Asterisk AudioSocket, RTP, or a browser WebSocket. Optional ElevenLabs cloud voices with automatic local fallback.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/ictinnovations/piper-tts-server/actions)
|
|
6
|
+
[](https://pypi.org/project/piper-tts-server/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
If you have tried to bolt Piper onto a phone call and hit **dead air before the greeting**, **the first few words clipped**, or **the caller only hearing the tail of each sentence** — this library is the fix. It packages the three things that make Piper work in a real-time loop:
|
|
10
|
+
|
|
11
|
+
1. **Process-wide voice cache.** `PiperVoice.load()` costs 2.5–5.5 s. Loading per request means that much dead air before every utterance. Voices load once and are reused.
|
|
12
|
+
2. **A single synthesis lock.** Piper phonemizes via espeak-ng, whose C API is **not thread-safe**. All synthesis is serialized behind one lock so concurrent calls don't corrupt each other.
|
|
13
|
+
3. **Deadline-based frame pacing.** Real-time transports forward each frame the instant it arrives; bursting a whole utterance overruns the far end's jitter buffer. `PacedWriter` releases exactly one frame per interval on a monotonic deadline — and **re-clamps every frame** so a synthesis stall never turns into a catch-up burst that clips the next words.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "piper-tts-server[all]" # library + server + piper + elevenlabs
|
|
19
|
+
# or minimal:
|
|
20
|
+
pip install piper-tts-server # library core (numpy only)
|
|
21
|
+
pip install "piper-tts-server[piper,server]"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Download a Piper voice (`.onnx` + `.onnx.json`) into a `voices/` directory — see the [Piper voices list](https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/voices.md).
|
|
25
|
+
|
|
26
|
+
## Run the service
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
PIPER_TTS_VOICES_DIR=voices PIPER_TTS_DEFAULT_VOICE=en_US-amy-medium \
|
|
30
|
+
piper-tts-server # listens on 127.0.0.1:8080
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# one-shot WAV
|
|
35
|
+
curl -X POST localhost:8080/synthesize \
|
|
36
|
+
-H 'Content-Type: application/json' \
|
|
37
|
+
-d '{"text":"Hello from Piper."}' --output hello.wav
|
|
38
|
+
|
|
39
|
+
# paced streaming slin16 frames (feed straight to AudioSocket/RTP)
|
|
40
|
+
curl -N -X POST localhost:8080/synthesize/stream \
|
|
41
|
+
-H 'Content-Type: application/json' \
|
|
42
|
+
-d '{"text":"Your call is being connected.","paced":true}' --output frames.slin
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Endpoint | Purpose |
|
|
46
|
+
|----------|---------|
|
|
47
|
+
| `GET /health` | status + cached voices |
|
|
48
|
+
| `POST /warm` `{voice_id}` | preload a voice into the cache |
|
|
49
|
+
| `POST /synthesize` `{text, voice_id, provider, format}` | one-shot `wav` (default) or raw `pcm` |
|
|
50
|
+
| `POST /synthesize/stream` `{text, …, paced}` | chunked frames; `paced:true` = deadline-clocked |
|
|
51
|
+
|
|
52
|
+
## Use as a library (no HTTP in the media path)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from piper_tts_server import StreamingTTS, PacedWriter, tts_sanitize
|
|
56
|
+
|
|
57
|
+
tts = StreamingTTS(voices_dir="voices", default_voice_path="voices/en_US-amy-medium.onnx")
|
|
58
|
+
await tts.start()
|
|
59
|
+
|
|
60
|
+
writer = PacedWriter(my_audiosocket_write, frame_ms=20) # sink: async fn(bytes)
|
|
61
|
+
async for frame in tts.synthesise(tts_sanitize(text)):
|
|
62
|
+
await writer.write(frame) # 320 bytes / 20 ms, paced
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See [`examples/asterisk_audiosocket.md`](examples/asterisk_audiosocket.md) for the full AudioSocket wiring and streaming-LLM sentence chunking.
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
All via env (or `ServerConfig`): `PIPER_TTS_HOST`, `PIPER_TTS_PORT`, `PIPER_TTS_VOICES_DIR`, `PIPER_TTS_DEFAULT_VOICE`, `PIPER_TTS_PROVIDER` (`piper`|`elevenlabs`), `PIPER_TTS_ELEVENLABS_API_KEY`, `PIPER_TTS_SAMPLE_RATE` (default `8000`), `PIPER_TTS_FRAME_MS` (default `20`), `PIPER_TTS_WARM_ON_START`.
|
|
70
|
+
|
|
71
|
+
Default output is **8 kHz slin16 / 20 ms frames** (telephony). Set `PIPER_TTS_SAMPLE_RATE=16000` for wideband.
|
|
72
|
+
|
|
73
|
+
### ElevenLabs with local fallback
|
|
74
|
+
|
|
75
|
+
Set `PIPER_TTS_PROVIDER=elevenlabs` + `PIPER_TTS_ELEVENLABS_API_KEY` and pass a `voice_id`. Any failure falls back to the local Piper default voice so the stream never goes silent; a `401/403` disables further cloud attempts for that engine.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install "piper-tts-server[dev]"
|
|
81
|
+
pytest # pure-DSP, sanitize, and pacing tests run without Piper installed
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Licensing
|
|
85
|
+
|
|
86
|
+
This package is **MIT licensed** and contains no Piper source code. Piper is an optional extra, imported lazily only when the local engine is used — the core library installs with numpy alone, and the test suite runs without Piper present.
|
|
87
|
+
|
|
88
|
+
Piper itself is a separate project under a different licence. The maintained release ([`OHF-Voice/piper1-gpl`](https://github.com/OHF-Voice/piper1-gpl), PyPI [`piper-tts`](https://pypi.org/project/piper-tts/)) is **GPL-3.0-or-later**; the older MIT `rhasspy/piper` is archived. If you install the `[piper]` or `[all]` extra, the resulting combined installation is subject to GPL-3.0 terms. The ElevenLabs provider, and the pacing and sanitising helpers on their own, pull in no GPL code.
|
|
89
|
+
|
|
90
|
+
**Not affiliated with, or endorsed by, the Open Home Foundation or the Piper project.** This is an independent server and library that drives Piper as one of several backends.
|
|
91
|
+
|
|
92
|
+
## Provenance & credits
|
|
93
|
+
|
|
94
|
+
Extracted and generalized from the TTS layer of **[ICTContact](https://www.ictcontact.com)**'s AI Voice Agent, by **[ICT Innovations](https://www.ictinnovations.com)** / **[ICT Vision](https://ict.vision)**. Author: **Tahir Almas**. Companion project: [`asterisk-ai-voice-agent`](https://github.com/ictinnovations/asterisk-ai-voice-agent).
|
|
95
|
+
|
|
96
|
+
MIT licensed — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""piper-tts-server: a paced, streaming Piper (+ElevenLabs) TTS microservice."""
|
|
2
|
+
|
|
3
|
+
from .tts import StreamingTTS, resample_linear, resolve_voice_path, warm, pcm_to_wav_bytes
|
|
4
|
+
from .pacing import PacedWriter, make_silence_frame
|
|
5
|
+
from .sanitize import tts_sanitize, split_for_tts
|
|
6
|
+
from .config import ServerConfig
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"StreamingTTS", "resample_linear", "resolve_voice_path", "warm", "pcm_to_wav_bytes",
|
|
12
|
+
"PacedWriter", "make_silence_frame",
|
|
13
|
+
"tts_sanitize", "split_for_tts",
|
|
14
|
+
"ServerConfig",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Runtime configuration, sourced from constructor args or environment."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ServerConfig:
|
|
10
|
+
host: str = "127.0.0.1"
|
|
11
|
+
port: int = 8080
|
|
12
|
+
voices_dir: str = "voices"
|
|
13
|
+
default_voice: Optional[str] = None # basename without .onnx, or a path
|
|
14
|
+
provider: str = "piper" # piper | elevenlabs
|
|
15
|
+
elevenlabs_api_key: str = ""
|
|
16
|
+
target_sample_rate: int = 8000
|
|
17
|
+
frame_ms: int = 20
|
|
18
|
+
warm_on_start: bool = True # preload default voice at boot
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_env(cls) -> "ServerConfig":
|
|
22
|
+
def _b(name: str, default: bool) -> bool:
|
|
23
|
+
v = os.environ.get(name)
|
|
24
|
+
return default if v is None else v.strip().lower() in ("1", "true", "yes", "on")
|
|
25
|
+
return cls(
|
|
26
|
+
host=os.environ.get("PIPER_TTS_HOST", cls.host),
|
|
27
|
+
port=int(os.environ.get("PIPER_TTS_PORT", cls.port)),
|
|
28
|
+
voices_dir=os.environ.get("PIPER_TTS_VOICES_DIR", cls.voices_dir),
|
|
29
|
+
default_voice=os.environ.get("PIPER_TTS_DEFAULT_VOICE") or None,
|
|
30
|
+
provider=os.environ.get("PIPER_TTS_PROVIDER", cls.provider),
|
|
31
|
+
elevenlabs_api_key=os.environ.get("PIPER_TTS_ELEVENLABS_API_KEY", ""),
|
|
32
|
+
target_sample_rate=int(os.environ.get("PIPER_TTS_SAMPLE_RATE", cls.target_sample_rate)),
|
|
33
|
+
frame_ms=int(os.environ.get("PIPER_TTS_FRAME_MS", cls.frame_ms)),
|
|
34
|
+
warm_on_start=_b("PIPER_TTS_WARM_ON_START", cls.warm_on_start),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def default_voice_path(self) -> Optional[str]:
|
|
38
|
+
"""Resolve default_voice (basename or path) to a filesystem path."""
|
|
39
|
+
if not self.default_voice:
|
|
40
|
+
return None
|
|
41
|
+
if os.path.isfile(self.default_voice):
|
|
42
|
+
return self.default_voice
|
|
43
|
+
cand = os.path.join(self.voices_dir, f"{self.default_voice}.onnx")
|
|
44
|
+
return cand if os.path.isfile(cand) else None
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deadline-based frame pacing.
|
|
3
|
+
|
|
4
|
+
Why pace at all
|
|
5
|
+
---------------
|
|
6
|
+
When TTS output is forwarded to a real-time transport (Asterisk AudioSocket,
|
|
7
|
+
an RTP leg, a WebSocket to a browser), each frame is sent onward the instant
|
|
8
|
+
it arrives. Bursting a whole utterance overruns the far end's ~100-200 ms
|
|
9
|
+
jitter buffer and the listener hears only the tail of each chunk.
|
|
10
|
+
|
|
11
|
+
The fix is to release exactly one frame per frame-interval on a *deadline*
|
|
12
|
+
clock, never faster. The subtle bug this solves (and re-solves every frame):
|
|
13
|
+
if synthesis latency stalls the loop, the deadline goes stale and the next
|
|
14
|
+
frames would burst to "catch up". `PacedWriter.write` re-clamps the deadline to
|
|
15
|
+
`now` every frame, so a stall never produces a burst -- pacing resumes cleanly
|
|
16
|
+
from the present instant.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import time
|
|
21
|
+
from typing import Awaitable, Callable, Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PacedWriter:
|
|
25
|
+
"""Releases frames at a fixed cadence on a monotonic deadline clock.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
sink:
|
|
30
|
+
async callable invoked with each frame's bytes (write to socket, etc.).
|
|
31
|
+
frame_ms:
|
|
32
|
+
frame interval in milliseconds (default 20).
|
|
33
|
+
clock:
|
|
34
|
+
monotonic time source in seconds. Defaults to a loop-relative clock;
|
|
35
|
+
pass ``time.monotonic`` for a loop-independent one.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, sink: Callable[[bytes], Awaitable[None]],
|
|
39
|
+
frame_ms: int = 20,
|
|
40
|
+
clock: Optional[Callable[[], float]] = None):
|
|
41
|
+
self._sink = sink
|
|
42
|
+
self._frame_sec = frame_ms / 1000.0
|
|
43
|
+
self._clock = clock
|
|
44
|
+
self._deadline = 0.0
|
|
45
|
+
self.frames_out = 0
|
|
46
|
+
|
|
47
|
+
def _now(self) -> float:
|
|
48
|
+
if self._clock is not None:
|
|
49
|
+
return self._clock()
|
|
50
|
+
return asyncio.get_event_loop().time()
|
|
51
|
+
|
|
52
|
+
def reset(self) -> None:
|
|
53
|
+
"""Drop the accumulated deadline (call before a fresh talk-spurt)."""
|
|
54
|
+
self._deadline = 0.0
|
|
55
|
+
|
|
56
|
+
async def write(self, frame: bytes) -> None:
|
|
57
|
+
"""Send one frame, then sleep until its pacing deadline."""
|
|
58
|
+
now = self._now()
|
|
59
|
+
# Re-clamp: never let a stalled deadline cause a burst.
|
|
60
|
+
self._deadline = max(self._deadline, now)
|
|
61
|
+
await self._sink(frame)
|
|
62
|
+
self.frames_out += 1
|
|
63
|
+
self._deadline += self._frame_sec
|
|
64
|
+
delay = self._deadline - self._now()
|
|
65
|
+
if delay > 0:
|
|
66
|
+
await asyncio.sleep(delay)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def make_silence_frame(sample_rate: int = 8000, frame_ms: int = 20) -> bytes:
|
|
70
|
+
"""A single silent mono 16-bit PCM frame (useful for pre-roll / keepalive)."""
|
|
71
|
+
return b"\x00" * (int(sample_rate * (frame_ms / 1000.0)) * 2)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text preparation for TTS.
|
|
3
|
+
|
|
4
|
+
LLM output is written for chat: markdown emphasis and emoji that a TTS engine
|
|
5
|
+
would try to pronounce ("asterisk asterisk...") or garble. `tts_sanitize`
|
|
6
|
+
strips to plain speakable text. `split_for_tts` chunks streaming LLM output at
|
|
7
|
+
sentence boundaries so synthesis can start before the full reply arrives --
|
|
8
|
+
including Urdu full stop (U+06D4), Arabic question mark (U+061F), and
|
|
9
|
+
Devanagari danda (U+0964), which the ASCII-only default would miss.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import List, Tuple
|
|
14
|
+
|
|
15
|
+
# Sentence end: latin/urdu/devanagari terminators, or a clause break (comma,
|
|
16
|
+
# semicolon, colon) followed by more text -- lets synthesis start mid-reply.
|
|
17
|
+
_SENT_END = re.compile(r"([.!?؟।۔]\s|[,;:]\s(?=\S))")
|
|
18
|
+
|
|
19
|
+
_MD_STRIP = re.compile(r"[*_`#~]+")
|
|
20
|
+
_NON_SPEAKABLE = re.compile(
|
|
21
|
+
"["
|
|
22
|
+
"\U0001F000-\U0001FAFF" # emoji & symbols
|
|
23
|
+
"\U00002190-\U000021FF" # arrows
|
|
24
|
+
"\U00002600-\U000027BF" # misc symbols / dingbats
|
|
25
|
+
"\U0001F1E6-\U0001F1FF" # regional indicators
|
|
26
|
+
"️" # variation selector / zero-width joiner
|
|
27
|
+
"]+"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tts_sanitize(text: str) -> str:
|
|
32
|
+
"""Strip markdown and non-speakable glyphs; collapse whitespace."""
|
|
33
|
+
text = _MD_STRIP.sub("", text)
|
|
34
|
+
text = _NON_SPEAKABLE.sub("", text)
|
|
35
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def split_for_tts(buf: str, force_flush: bool = False) -> Tuple[List[str], str]:
|
|
39
|
+
"""Return (complete chunks ready for TTS, remaining buffer).
|
|
40
|
+
|
|
41
|
+
Feed streaming LLM deltas in; emit each completed sentence/clause as soon
|
|
42
|
+
as it closes. Pass force_flush=True at end-of-stream to flush the tail.
|
|
43
|
+
"""
|
|
44
|
+
chunks: List[str] = []
|
|
45
|
+
remaining = buf
|
|
46
|
+
while True:
|
|
47
|
+
m = _SENT_END.search(remaining)
|
|
48
|
+
if not m:
|
|
49
|
+
break
|
|
50
|
+
end = m.end()
|
|
51
|
+
chunks.append(remaining[:end].strip())
|
|
52
|
+
remaining = remaining[end:]
|
|
53
|
+
if force_flush and remaining.strip():
|
|
54
|
+
chunks.append(remaining.strip())
|
|
55
|
+
remaining = ""
|
|
56
|
+
return chunks, remaining
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP microservice around StreamingTTS (aiohttp).
|
|
3
|
+
|
|
4
|
+
Endpoints
|
|
5
|
+
---------
|
|
6
|
+
GET /health
|
|
7
|
+
-> {"status":"ok","provider":..,"sample_rate":..,"cached_voices":[..]}
|
|
8
|
+
|
|
9
|
+
POST /warm {"voice_id": "en_US-amy-medium"}
|
|
10
|
+
Preload a voice into the process-wide cache. Returns {"warmed": path}.
|
|
11
|
+
|
|
12
|
+
POST /synthesize {"text": "...", "voice_id": "...", "provider": "...",
|
|
13
|
+
"format": "wav"|"pcm"}
|
|
14
|
+
One-shot synthesis. Returns audio/wav (default) or raw slin16 PCM
|
|
15
|
+
(application/octet-stream) at the configured sample rate.
|
|
16
|
+
|
|
17
|
+
POST /synthesize/stream same body, plus optional "paced": true
|
|
18
|
+
Chunked transfer of fixed-size frames (frame_ms). With "paced": true the
|
|
19
|
+
frames are released on the pacing deadline clock -- feed this straight to
|
|
20
|
+
a real-time transport. Without it, frames are sent as fast as synthesized.
|
|
21
|
+
|
|
22
|
+
A per-voice StreamingTTS is cached and reused; the underlying PiperVoice is
|
|
23
|
+
shared process-wide via tts._VOICE_CACHE.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import logging
|
|
28
|
+
from typing import Dict
|
|
29
|
+
|
|
30
|
+
from aiohttp import web
|
|
31
|
+
|
|
32
|
+
from .config import ServerConfig
|
|
33
|
+
from .pacing import PacedWriter
|
|
34
|
+
from . import tts as tts_mod
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("piper_tts_server.server")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TTSService:
|
|
40
|
+
def __init__(self, cfg: ServerConfig):
|
|
41
|
+
self.cfg = cfg
|
|
42
|
+
self._engines: Dict[str, tts_mod.StreamingTTS] = {}
|
|
43
|
+
self._lock = asyncio.Lock()
|
|
44
|
+
|
|
45
|
+
async def engine_for(self, voice_id: str, provider: str) -> tts_mod.StreamingTTS:
|
|
46
|
+
key = f"{provider}:{voice_id}"
|
|
47
|
+
eng = self._engines.get(key)
|
|
48
|
+
if eng is not None:
|
|
49
|
+
return eng
|
|
50
|
+
async with self._lock:
|
|
51
|
+
eng = self._engines.get(key)
|
|
52
|
+
if eng is None:
|
|
53
|
+
eng = tts_mod.StreamingTTS(
|
|
54
|
+
provider=provider,
|
|
55
|
+
voice_id=voice_id or self.cfg.default_voice,
|
|
56
|
+
api_key=self.cfg.elevenlabs_api_key,
|
|
57
|
+
voices_dir=self.cfg.voices_dir,
|
|
58
|
+
default_voice_path=self.cfg.default_voice_path(),
|
|
59
|
+
target_sample_rate=self.cfg.target_sample_rate,
|
|
60
|
+
frame_ms=self.cfg.frame_ms,
|
|
61
|
+
)
|
|
62
|
+
await eng.start()
|
|
63
|
+
self._engines[key] = eng
|
|
64
|
+
return eng
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def health(request: web.Request) -> web.Response:
|
|
68
|
+
cfg: ServerConfig = request.app["cfg"]
|
|
69
|
+
return web.json_response({
|
|
70
|
+
"status": "ok",
|
|
71
|
+
"provider": cfg.provider,
|
|
72
|
+
"sample_rate": cfg.target_sample_rate,
|
|
73
|
+
"frame_ms": cfg.frame_ms,
|
|
74
|
+
"cached_voices": sorted(tts_mod._VOICE_CACHE.keys()),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def warm(request: web.Request) -> web.Response:
|
|
79
|
+
cfg: ServerConfig = request.app["cfg"]
|
|
80
|
+
body = await request.json()
|
|
81
|
+
voice_id = body.get("voice_id") or cfg.default_voice
|
|
82
|
+
path = tts_mod.resolve_voice_path(voice_id, cfg.voices_dir, cfg.default_voice_path())
|
|
83
|
+
await tts_mod.warm(path)
|
|
84
|
+
return web.json_response({"warmed": path})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def synthesize(request: web.Request) -> web.Response:
|
|
88
|
+
cfg: ServerConfig = request.app["cfg"]
|
|
89
|
+
svc: TTSService = request.app["svc"]
|
|
90
|
+
body = await request.json()
|
|
91
|
+
text = (body.get("text") or "").strip()
|
|
92
|
+
if not text:
|
|
93
|
+
return web.json_response({"error": "text required"}, status=400)
|
|
94
|
+
provider = body.get("provider") or cfg.provider
|
|
95
|
+
voice_id = body.get("voice_id") or cfg.default_voice
|
|
96
|
+
fmt = (body.get("format") or "wav").lower()
|
|
97
|
+
|
|
98
|
+
eng = await svc.engine_for(voice_id, provider)
|
|
99
|
+
pcm = await eng.synthesise_pcm(text)
|
|
100
|
+
|
|
101
|
+
if fmt == "pcm":
|
|
102
|
+
return web.Response(body=pcm.tobytes(), content_type="application/octet-stream",
|
|
103
|
+
headers={"X-Sample-Rate": str(cfg.target_sample_rate)})
|
|
104
|
+
wav = tts_mod.pcm_to_wav_bytes(pcm, cfg.target_sample_rate)
|
|
105
|
+
return web.Response(body=wav, content_type="audio/wav")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def synthesize_stream(request: web.Request) -> web.StreamResponse:
|
|
109
|
+
cfg: ServerConfig = request.app["cfg"]
|
|
110
|
+
svc: TTSService = request.app["svc"]
|
|
111
|
+
body = await request.json()
|
|
112
|
+
text = (body.get("text") or "").strip()
|
|
113
|
+
if not text:
|
|
114
|
+
return web.json_response({"error": "text required"}, status=400)
|
|
115
|
+
provider = body.get("provider") or cfg.provider
|
|
116
|
+
voice_id = body.get("voice_id") or cfg.default_voice
|
|
117
|
+
paced = bool(body.get("paced", False))
|
|
118
|
+
|
|
119
|
+
eng = await svc.engine_for(voice_id, provider)
|
|
120
|
+
|
|
121
|
+
resp = web.StreamResponse(status=200, headers={
|
|
122
|
+
"Content-Type": "application/octet-stream",
|
|
123
|
+
"X-Sample-Rate": str(cfg.target_sample_rate),
|
|
124
|
+
"X-Frame-Ms": str(cfg.frame_ms),
|
|
125
|
+
})
|
|
126
|
+
await resp.prepare(request)
|
|
127
|
+
|
|
128
|
+
if paced:
|
|
129
|
+
writer = PacedWriter(resp.write, frame_ms=cfg.frame_ms)
|
|
130
|
+
async for frame in eng.synthesise(text):
|
|
131
|
+
await writer.write(frame)
|
|
132
|
+
else:
|
|
133
|
+
async for frame in eng.synthesise(text):
|
|
134
|
+
await resp.write(frame)
|
|
135
|
+
await resp.write_eof()
|
|
136
|
+
return resp
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def build_app(cfg: ServerConfig) -> web.Application:
|
|
140
|
+
app = web.Application()
|
|
141
|
+
app["cfg"] = cfg
|
|
142
|
+
app["svc"] = TTSService(cfg)
|
|
143
|
+
app.add_routes([
|
|
144
|
+
web.get("/health", health),
|
|
145
|
+
web.post("/warm", warm),
|
|
146
|
+
web.post("/synthesize", synthesize),
|
|
147
|
+
web.post("/synthesize/stream", synthesize_stream),
|
|
148
|
+
])
|
|
149
|
+
|
|
150
|
+
async def _on_start(app: web.Application) -> None:
|
|
151
|
+
if cfg.warm_on_start and cfg.provider == "piper":
|
|
152
|
+
path = cfg.default_voice_path()
|
|
153
|
+
if path:
|
|
154
|
+
try:
|
|
155
|
+
await tts_mod.warm(path)
|
|
156
|
+
except Exception as exc: # pragma: no cover
|
|
157
|
+
log.warning("warm_on_start failed: %s", exc)
|
|
158
|
+
|
|
159
|
+
app.on_startup.append(_on_start)
|
|
160
|
+
return app
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def run(cfg: ServerConfig = None) -> None:
|
|
164
|
+
logging.basicConfig(level=logging.INFO,
|
|
165
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
166
|
+
cfg = cfg or ServerConfig.from_env()
|
|
167
|
+
log.info("piper-tts-server on %s:%d (provider=%s, %d Hz, voices=%s)",
|
|
168
|
+
cfg.host, cfg.port, cfg.provider, cfg.target_sample_rate, cfg.voices_dir)
|
|
169
|
+
web.run_app(build_app(cfg), host=cfg.host, port=cfg.port, print=None)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Streaming TTS engine: Piper (local, ONNX) + optional ElevenLabs (cloud).
|
|
3
|
+
|
|
4
|
+
Synthesizes text to PCM, resamples to the target sample rate, and yields
|
|
5
|
+
fixed-size frames (default 20 ms @ 8 kHz slin16 -- the Asterisk AudioSocket
|
|
6
|
+
format) so callers can stream them straight into a paced writer, an
|
|
7
|
+
AudioSocket connection, a WebSocket, or a WAV file.
|
|
8
|
+
|
|
9
|
+
Why this exists as a standalone package
|
|
10
|
+
---------------------------------------
|
|
11
|
+
Two hard-won details make Piper usable in a real-time voice pipeline, and
|
|
12
|
+
both are baked in here:
|
|
13
|
+
|
|
14
|
+
1. Process-wide voice cache. `PiperVoice.load()` takes 2.5-5.5 s (ONNX
|
|
15
|
+
model load). Loading per request means every call starts with that
|
|
16
|
+
much dead air. Voices are cached process-wide, keyed by path.
|
|
17
|
+
|
|
18
|
+
2. A single synthesis lock. Piper phonemizes via espeak-ng, whose C API
|
|
19
|
+
is NOT thread-safe, and a cached PiperVoice is shared across requests.
|
|
20
|
+
All synthesis is serialized behind one asyncio.Lock.
|
|
21
|
+
|
|
22
|
+
Providers (see StreamingTTS.provider):
|
|
23
|
+
piper -- local ONNX voice under `voices_dir`. Default voice is
|
|
24
|
+
configurable; resolution prefers `{voices_dir}/{voice_id}.onnx`.
|
|
25
|
+
elevenlabs -- cloud API (needs api_key + voice_id). Audio is requested as
|
|
26
|
+
pcm_16000 and resampled. Any failure falls back to the local
|
|
27
|
+
Piper default voice so the stream never goes silent; a
|
|
28
|
+
401/403 disables further ElevenLabs attempts for the session.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import io
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import wave
|
|
36
|
+
from typing import AsyncIterator, Optional
|
|
37
|
+
|
|
38
|
+
import numpy as np
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger("piper_tts_server.tts")
|
|
41
|
+
|
|
42
|
+
# ---- Defaults (all overridable via TTSConfig / constructor) ----------------
|
|
43
|
+
DEFAULT_TARGET_SAMPLE_RATE = 8000 # slin16 / Asterisk AudioSocket
|
|
44
|
+
DEFAULT_FRAME_MS = 20
|
|
45
|
+
|
|
46
|
+
ELEVEN_URL_TMPL = "https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream"
|
|
47
|
+
ELEVEN_DEFAULT_MODEL = "eleven_flash_v2_5"
|
|
48
|
+
ELEVEN_SAMPLE_RATE = 16000 # we request output_format=pcm_16000
|
|
49
|
+
|
|
50
|
+
# Process-wide voice cache + a single synthesis lock (espeak-ng phonemizer is
|
|
51
|
+
# not thread-safe; a cached PiperVoice is shared across requests).
|
|
52
|
+
_VOICE_CACHE: dict = {}
|
|
53
|
+
_VOICE_LOCK: Optional[asyncio.Lock] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _frame_bytes(sample_rate: int, frame_ms: int) -> int:
|
|
57
|
+
"""Bytes in one mono 16-bit PCM frame of `frame_ms` at `sample_rate`."""
|
|
58
|
+
return int(sample_rate * (frame_ms / 1000.0)) * 2
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_voice_path(voice_id: Optional[str], voices_dir: str,
|
|
62
|
+
default_voice_path: Optional[str] = None) -> str:
|
|
63
|
+
"""
|
|
64
|
+
Pick a voice .onnx file. Priority:
|
|
65
|
+
1. {voices_dir}/{voice_id}.onnx if voice_id given and the file exists
|
|
66
|
+
2. default_voice_path if it exists
|
|
67
|
+
3. the single .onnx in voices_dir if exactly one is present
|
|
68
|
+
Raises RuntimeError if nothing resolves.
|
|
69
|
+
"""
|
|
70
|
+
if voice_id:
|
|
71
|
+
candidate = os.path.join(voices_dir, f"{voice_id}.onnx")
|
|
72
|
+
if os.path.isfile(candidate):
|
|
73
|
+
return candidate
|
|
74
|
+
log.warning("voice_id=%r requested but %s not found", voice_id, candidate)
|
|
75
|
+
if default_voice_path and os.path.isfile(default_voice_path):
|
|
76
|
+
return default_voice_path
|
|
77
|
+
if os.path.isdir(voices_dir):
|
|
78
|
+
onnx = [f for f in os.listdir(voices_dir) if f.endswith(".onnx")]
|
|
79
|
+
if len(onnx) == 1:
|
|
80
|
+
return os.path.join(voices_dir, onnx[0])
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"no Piper voice available (voice_id={voice_id}, "
|
|
83
|
+
f"default={default_voice_path}, voices_dir={voices_dir})")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resample_linear(pcm16: np.ndarray, src_sr: int, dst_sr: int) -> np.ndarray:
|
|
87
|
+
"""Cheap linear resampler. Aliasing acceptable for <=16 kHz phone audio."""
|
|
88
|
+
if src_sr == dst_sr:
|
|
89
|
+
return pcm16
|
|
90
|
+
ratio = dst_sr / src_sr
|
|
91
|
+
out_len = int(len(pcm16) * ratio)
|
|
92
|
+
if out_len <= 1:
|
|
93
|
+
return np.zeros(0, dtype=np.int16)
|
|
94
|
+
src_idx = np.linspace(0, len(pcm16) - 1, out_len, dtype=np.float32)
|
|
95
|
+
lo = src_idx.astype(np.int32)
|
|
96
|
+
hi = np.minimum(lo + 1, len(pcm16) - 1)
|
|
97
|
+
frac = src_idx - lo
|
|
98
|
+
out = (1.0 - frac) * pcm16[lo].astype(np.float32) + frac * pcm16[hi].astype(np.float32)
|
|
99
|
+
return out.astype(np.int16)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def iter_frames(pcm: np.ndarray, frame_bytes: int) -> AsyncIterator[bytes]:
|
|
103
|
+
"""Yield PCM as fixed-size frames, padding the final partial frame with
|
|
104
|
+
silence to keep a constant cadence."""
|
|
105
|
+
view = pcm.tobytes()
|
|
106
|
+
for off in range(0, len(view), frame_bytes):
|
|
107
|
+
chunk = view[off:off + frame_bytes]
|
|
108
|
+
if len(chunk) < frame_bytes:
|
|
109
|
+
chunk = chunk + b"\x00" * (frame_bytes - len(chunk))
|
|
110
|
+
yield chunk
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def pcm_to_wav_bytes(pcm: np.ndarray, sample_rate: int) -> bytes:
|
|
114
|
+
"""Wrap slin16 mono PCM in a WAV container (for the non-streaming API)."""
|
|
115
|
+
buf = io.BytesIO()
|
|
116
|
+
with wave.open(buf, "wb") as wf:
|
|
117
|
+
wf.setnchannels(1)
|
|
118
|
+
wf.setsampwidth(2)
|
|
119
|
+
wf.setframerate(sample_rate)
|
|
120
|
+
wf.writeframes(pcm.tobytes())
|
|
121
|
+
return buf.getvalue()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class StreamingTTS:
|
|
125
|
+
"""One synthesis context. Reusable across many synthesise() calls."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, provider: str = "piper", voice_id: Optional[str] = None,
|
|
128
|
+
model: str = "", api_key: Optional[str] = None,
|
|
129
|
+
voices_dir: str = "voices",
|
|
130
|
+
default_voice_path: Optional[str] = None,
|
|
131
|
+
target_sample_rate: int = DEFAULT_TARGET_SAMPLE_RATE,
|
|
132
|
+
frame_ms: int = DEFAULT_FRAME_MS):
|
|
133
|
+
self.provider = (provider or "piper").strip().lower()
|
|
134
|
+
self.voice_id = voice_id
|
|
135
|
+
self.model = model
|
|
136
|
+
self.api_key = api_key or ""
|
|
137
|
+
self.voices_dir = voices_dir
|
|
138
|
+
self.default_voice_path = default_voice_path
|
|
139
|
+
self.target_sr = int(target_sample_rate)
|
|
140
|
+
self.frame_ms = int(frame_ms)
|
|
141
|
+
self.frame_bytes = _frame_bytes(self.target_sr, self.frame_ms)
|
|
142
|
+
self.voice_path = default_voice_path
|
|
143
|
+
self._voice = None
|
|
144
|
+
self._native_sr = None
|
|
145
|
+
self._eleven = False
|
|
146
|
+
self._eleven_disabled = False
|
|
147
|
+
self._client = None
|
|
148
|
+
|
|
149
|
+
async def start(self) -> None:
|
|
150
|
+
if self.provider == "elevenlabs":
|
|
151
|
+
if self.api_key and self.voice_id:
|
|
152
|
+
import httpx # lazy: only needed for cloud TTS
|
|
153
|
+
self._client = httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=5.0))
|
|
154
|
+
self._eleven = True
|
|
155
|
+
log.info("TTS ready: elevenlabs voice=%s model=%s (piper fallback armed)",
|
|
156
|
+
self.voice_id, self.model or ELEVEN_DEFAULT_MODEL)
|
|
157
|
+
return
|
|
158
|
+
log.warning("provider=elevenlabs but %s missing; using piper",
|
|
159
|
+
"api_key" if not self.api_key else "voice_id")
|
|
160
|
+
elif self.provider != "piper":
|
|
161
|
+
log.warning("provider=%s not implemented; using piper", self.provider)
|
|
162
|
+
await self._ensure_piper()
|
|
163
|
+
log.info("TTS ready: voice=%s native_sr=%d -> %d Hz",
|
|
164
|
+
os.path.basename(self.voice_path), self._native_sr, self.target_sr)
|
|
165
|
+
|
|
166
|
+
async def _ensure_piper(self) -> None:
|
|
167
|
+
"""Load (or fetch from cache) the local Piper voice."""
|
|
168
|
+
global _VOICE_LOCK
|
|
169
|
+
if self._voice is not None:
|
|
170
|
+
return
|
|
171
|
+
piper_voice_id = None if self._eleven else self.voice_id
|
|
172
|
+
self.voice_path = resolve_voice_path(piper_voice_id, self.voices_dir,
|
|
173
|
+
self.default_voice_path)
|
|
174
|
+
log.info("TTS voice file resolved: %s", self.voice_path)
|
|
175
|
+
if _VOICE_LOCK is None:
|
|
176
|
+
_VOICE_LOCK = asyncio.Lock()
|
|
177
|
+
async with _VOICE_LOCK:
|
|
178
|
+
voice = _VOICE_CACHE.get(self.voice_path)
|
|
179
|
+
if voice is None:
|
|
180
|
+
from piper import PiperVoice # lazy: clean import without piper installed
|
|
181
|
+
voice = await asyncio.to_thread(PiperVoice.load, self.voice_path)
|
|
182
|
+
_VOICE_CACHE[self.voice_path] = voice
|
|
183
|
+
log.info("TTS voice loaded + cached: %s", os.path.basename(self.voice_path))
|
|
184
|
+
self._voice = voice
|
|
185
|
+
self._native_sr = self._voice.config.sample_rate
|
|
186
|
+
|
|
187
|
+
async def _eleven_pcm(self, text: str) -> np.ndarray:
|
|
188
|
+
url = ELEVEN_URL_TMPL.format(voice_id=self.voice_id)
|
|
189
|
+
model = self.model if (self.model or "").startswith("eleven") else ELEVEN_DEFAULT_MODEL
|
|
190
|
+
resp = await self._client.post(
|
|
191
|
+
url,
|
|
192
|
+
params={"output_format": f"pcm_{ELEVEN_SAMPLE_RATE}"},
|
|
193
|
+
headers={"xi-api-key": self.api_key},
|
|
194
|
+
json={"text": text, "model_id": model},
|
|
195
|
+
)
|
|
196
|
+
resp.raise_for_status()
|
|
197
|
+
raw = resp.content
|
|
198
|
+
pcm = np.frombuffer(raw[: len(raw) // 2 * 2], dtype=np.int16)
|
|
199
|
+
return resample_linear(pcm, ELEVEN_SAMPLE_RATE, self.target_sr)
|
|
200
|
+
|
|
201
|
+
async def _piper_pcm(self, text: str) -> np.ndarray:
|
|
202
|
+
await self._ensure_piper()
|
|
203
|
+
|
|
204
|
+
def _synth() -> bytes:
|
|
205
|
+
buf = io.BytesIO()
|
|
206
|
+
with wave.open(buf, "wb") as wf:
|
|
207
|
+
self._voice.synthesize(text, wf)
|
|
208
|
+
buf.seek(0)
|
|
209
|
+
with wave.open(buf) as wf:
|
|
210
|
+
return wf.readframes(wf.getnframes())
|
|
211
|
+
|
|
212
|
+
# Serialize synthesis: espeak-ng phonemizer is not thread-safe and the
|
|
213
|
+
# PiperVoice is shared across requests.
|
|
214
|
+
async with _VOICE_LOCK:
|
|
215
|
+
pcm_bytes = await asyncio.to_thread(_synth)
|
|
216
|
+
pcm = np.frombuffer(pcm_bytes, dtype=np.int16)
|
|
217
|
+
if self._native_sr != self.target_sr:
|
|
218
|
+
pcm = resample_linear(pcm, self._native_sr, self.target_sr)
|
|
219
|
+
return pcm
|
|
220
|
+
|
|
221
|
+
async def synthesise_pcm(self, text: str) -> np.ndarray:
|
|
222
|
+
"""Return the whole utterance as one slin16 PCM array at target_sr."""
|
|
223
|
+
if not text.strip():
|
|
224
|
+
return np.zeros(0, dtype=np.int16)
|
|
225
|
+
pcm = None
|
|
226
|
+
if self._eleven and not self._eleven_disabled:
|
|
227
|
+
try:
|
|
228
|
+
pcm = await self._eleven_pcm(text)
|
|
229
|
+
except Exception as exc:
|
|
230
|
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
|
231
|
+
if status in (401, 403):
|
|
232
|
+
self._eleven_disabled = True
|
|
233
|
+
log.error("elevenlabs auth failed (%s); piper fallback for the session", status)
|
|
234
|
+
else:
|
|
235
|
+
log.warning("elevenlabs synthesis failed (%s); piper fallback for this utterance", exc)
|
|
236
|
+
if pcm is None:
|
|
237
|
+
pcm = await self._piper_pcm(text)
|
|
238
|
+
return pcm
|
|
239
|
+
|
|
240
|
+
async def synthesise(self, text: str) -> AsyncIterator[bytes]:
|
|
241
|
+
"""Yield slin16 PCM bytes as fixed-size (frame_ms) chunks."""
|
|
242
|
+
pcm = await self.synthesise_pcm(text)
|
|
243
|
+
for chunk in iter_frames(pcm, self.frame_bytes):
|
|
244
|
+
yield chunk
|
|
245
|
+
|
|
246
|
+
async def close(self) -> None:
|
|
247
|
+
self._voice = None
|
|
248
|
+
if self._client is not None:
|
|
249
|
+
try:
|
|
250
|
+
await self._client.aclose()
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
self._client = None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def warm(voice_path: str) -> None:
|
|
257
|
+
"""Preload a Piper voice into the process-wide cache (call at startup)."""
|
|
258
|
+
global _VOICE_LOCK
|
|
259
|
+
if _VOICE_LOCK is None:
|
|
260
|
+
_VOICE_LOCK = asyncio.Lock()
|
|
261
|
+
async with _VOICE_LOCK:
|
|
262
|
+
if voice_path in _VOICE_CACHE:
|
|
263
|
+
return
|
|
264
|
+
from piper import PiperVoice
|
|
265
|
+
_VOICE_CACHE[voice_path] = await asyncio.to_thread(PiperVoice.load, voice_path)
|
|
266
|
+
log.info("warmed voice: %s", os.path.basename(voice_path))
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piper-tts-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Paced, streaming Piper (+ElevenLabs) TTS microservice for real-time voice pipelines (Asterisk AudioSocket, RTP, WebSocket).
|
|
5
|
+
Author-email: Tahir Almas <tahir@ictinnovations.com>
|
|
6
|
+
Maintainer-email: ICT Innovations <info@ictinnovations.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/ictinnovations/piper-tts-server
|
|
9
|
+
Project-URL: Repository, https://github.com/ictinnovations/piper-tts-server
|
|
10
|
+
Project-URL: Derived From, https://www.ictcontact.com
|
|
11
|
+
Keywords: piper,tts,text-to-speech,asterisk,audiosocket,voice,streaming,realtime,voip
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: numpy>=1.21
|
|
22
|
+
Provides-Extra: piper
|
|
23
|
+
Requires-Dist: piper-tts>=1.2; extra == "piper"
|
|
24
|
+
Provides-Extra: server
|
|
25
|
+
Requires-Dist: aiohttp>=3.8; extra == "server"
|
|
26
|
+
Provides-Extra: elevenlabs
|
|
27
|
+
Requires-Dist: httpx>=0.24; extra == "elevenlabs"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: piper-tts>=1.2; extra == "all"
|
|
30
|
+
Requires-Dist: aiohttp>=3.8; extra == "all"
|
|
31
|
+
Requires-Dist: httpx>=0.24; extra == "all"
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
34
|
+
Requires-Dist: aiohttp>=3.8; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# piper-tts-server
|
|
38
|
+
|
|
39
|
+
**Paced, streaming [Piper](https://github.com/OHF-Voice/piper1-gpl) TTS for real-time voice pipelines** — Asterisk AudioSocket, RTP, or a browser WebSocket. Optional ElevenLabs cloud voices with automatic local fallback.
|
|
40
|
+
|
|
41
|
+
[](https://github.com/ictinnovations/piper-tts-server/actions)
|
|
42
|
+
[](https://pypi.org/project/piper-tts-server/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
|
|
45
|
+
If you have tried to bolt Piper onto a phone call and hit **dead air before the greeting**, **the first few words clipped**, or **the caller only hearing the tail of each sentence** — this library is the fix. It packages the three things that make Piper work in a real-time loop:
|
|
46
|
+
|
|
47
|
+
1. **Process-wide voice cache.** `PiperVoice.load()` costs 2.5–5.5 s. Loading per request means that much dead air before every utterance. Voices load once and are reused.
|
|
48
|
+
2. **A single synthesis lock.** Piper phonemizes via espeak-ng, whose C API is **not thread-safe**. All synthesis is serialized behind one lock so concurrent calls don't corrupt each other.
|
|
49
|
+
3. **Deadline-based frame pacing.** Real-time transports forward each frame the instant it arrives; bursting a whole utterance overruns the far end's jitter buffer. `PacedWriter` releases exactly one frame per interval on a monotonic deadline — and **re-clamps every frame** so a synthesis stall never turns into a catch-up burst that clips the next words.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "piper-tts-server[all]" # library + server + piper + elevenlabs
|
|
55
|
+
# or minimal:
|
|
56
|
+
pip install piper-tts-server # library core (numpy only)
|
|
57
|
+
pip install "piper-tts-server[piper,server]"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Download a Piper voice (`.onnx` + `.onnx.json`) into a `voices/` directory — see the [Piper voices list](https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/voices.md).
|
|
61
|
+
|
|
62
|
+
## Run the service
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
PIPER_TTS_VOICES_DIR=voices PIPER_TTS_DEFAULT_VOICE=en_US-amy-medium \
|
|
66
|
+
piper-tts-server # listens on 127.0.0.1:8080
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# one-shot WAV
|
|
71
|
+
curl -X POST localhost:8080/synthesize \
|
|
72
|
+
-H 'Content-Type: application/json' \
|
|
73
|
+
-d '{"text":"Hello from Piper."}' --output hello.wav
|
|
74
|
+
|
|
75
|
+
# paced streaming slin16 frames (feed straight to AudioSocket/RTP)
|
|
76
|
+
curl -N -X POST localhost:8080/synthesize/stream \
|
|
77
|
+
-H 'Content-Type: application/json' \
|
|
78
|
+
-d '{"text":"Your call is being connected.","paced":true}' --output frames.slin
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Endpoint | Purpose |
|
|
82
|
+
|----------|---------|
|
|
83
|
+
| `GET /health` | status + cached voices |
|
|
84
|
+
| `POST /warm` `{voice_id}` | preload a voice into the cache |
|
|
85
|
+
| `POST /synthesize` `{text, voice_id, provider, format}` | one-shot `wav` (default) or raw `pcm` |
|
|
86
|
+
| `POST /synthesize/stream` `{text, …, paced}` | chunked frames; `paced:true` = deadline-clocked |
|
|
87
|
+
|
|
88
|
+
## Use as a library (no HTTP in the media path)
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from piper_tts_server import StreamingTTS, PacedWriter, tts_sanitize
|
|
92
|
+
|
|
93
|
+
tts = StreamingTTS(voices_dir="voices", default_voice_path="voices/en_US-amy-medium.onnx")
|
|
94
|
+
await tts.start()
|
|
95
|
+
|
|
96
|
+
writer = PacedWriter(my_audiosocket_write, frame_ms=20) # sink: async fn(bytes)
|
|
97
|
+
async for frame in tts.synthesise(tts_sanitize(text)):
|
|
98
|
+
await writer.write(frame) # 320 bytes / 20 ms, paced
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [`examples/asterisk_audiosocket.md`](examples/asterisk_audiosocket.md) for the full AudioSocket wiring and streaming-LLM sentence chunking.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
All via env (or `ServerConfig`): `PIPER_TTS_HOST`, `PIPER_TTS_PORT`, `PIPER_TTS_VOICES_DIR`, `PIPER_TTS_DEFAULT_VOICE`, `PIPER_TTS_PROVIDER` (`piper`|`elevenlabs`), `PIPER_TTS_ELEVENLABS_API_KEY`, `PIPER_TTS_SAMPLE_RATE` (default `8000`), `PIPER_TTS_FRAME_MS` (default `20`), `PIPER_TTS_WARM_ON_START`.
|
|
106
|
+
|
|
107
|
+
Default output is **8 kHz slin16 / 20 ms frames** (telephony). Set `PIPER_TTS_SAMPLE_RATE=16000` for wideband.
|
|
108
|
+
|
|
109
|
+
### ElevenLabs with local fallback
|
|
110
|
+
|
|
111
|
+
Set `PIPER_TTS_PROVIDER=elevenlabs` + `PIPER_TTS_ELEVENLABS_API_KEY` and pass a `voice_id`. Any failure falls back to the local Piper default voice so the stream never goes silent; a `401/403` disables further cloud attempts for that engine.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install "piper-tts-server[dev]"
|
|
117
|
+
pytest # pure-DSP, sanitize, and pacing tests run without Piper installed
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Licensing
|
|
121
|
+
|
|
122
|
+
This package is **MIT licensed** and contains no Piper source code. Piper is an optional extra, imported lazily only when the local engine is used — the core library installs with numpy alone, and the test suite runs without Piper present.
|
|
123
|
+
|
|
124
|
+
Piper itself is a separate project under a different licence. The maintained release ([`OHF-Voice/piper1-gpl`](https://github.com/OHF-Voice/piper1-gpl), PyPI [`piper-tts`](https://pypi.org/project/piper-tts/)) is **GPL-3.0-or-later**; the older MIT `rhasspy/piper` is archived. If you install the `[piper]` or `[all]` extra, the resulting combined installation is subject to GPL-3.0 terms. The ElevenLabs provider, and the pacing and sanitising helpers on their own, pull in no GPL code.
|
|
125
|
+
|
|
126
|
+
**Not affiliated with, or endorsed by, the Open Home Foundation or the Piper project.** This is an independent server and library that drives Piper as one of several backends.
|
|
127
|
+
|
|
128
|
+
## Provenance & credits
|
|
129
|
+
|
|
130
|
+
Extracted and generalized from the TTS layer of **[ICTContact](https://www.ictcontact.com)**'s AI Voice Agent, by **[ICT Innovations](https://www.ictinnovations.com)** / **[ICT Vision](https://ict.vision)**. Author: **Tahir Almas**. Companion project: [`asterisk-ai-voice-agent`](https://github.com/ictinnovations/asterisk-ai-voice-agent).
|
|
131
|
+
|
|
132
|
+
MIT licensed — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
piper_tts_server/__init__.py
|
|
5
|
+
piper_tts_server/__main__.py
|
|
6
|
+
piper_tts_server/config.py
|
|
7
|
+
piper_tts_server/pacing.py
|
|
8
|
+
piper_tts_server/sanitize.py
|
|
9
|
+
piper_tts_server/server.py
|
|
10
|
+
piper_tts_server/tts.py
|
|
11
|
+
piper_tts_server.egg-info/PKG-INFO
|
|
12
|
+
piper_tts_server.egg-info/SOURCES.txt
|
|
13
|
+
piper_tts_server.egg-info/dependency_links.txt
|
|
14
|
+
piper_tts_server.egg-info/entry_points.txt
|
|
15
|
+
piper_tts_server.egg-info/requires.txt
|
|
16
|
+
piper_tts_server.egg-info/top_level.txt
|
|
17
|
+
tests/test_dsp.py
|
|
18
|
+
tests/test_pacing.py
|
|
19
|
+
tests/test_sanitize.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
piper_tts_server
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "piper-tts-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Paced, streaming Piper (+ElevenLabs) TTS microservice for real-time voice pipelines (Asterisk AudioSocket, RTP, WebSocket)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Tahir Almas", email = "tahir@ictinnovations.com" }]
|
|
13
|
+
maintainers = [{ name = "ICT Innovations", email = "info@ictinnovations.com" }]
|
|
14
|
+
keywords = ["piper", "tts", "text-to-speech", "asterisk", "audiosocket", "voice", "streaming", "realtime", "voip"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
21
|
+
"Topic :: Communications :: Telephony",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"numpy>=1.21",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
piper = ["piper-tts>=1.2"]
|
|
29
|
+
server = ["aiohttp>=3.8"]
|
|
30
|
+
elevenlabs = ["httpx>=0.24"]
|
|
31
|
+
all = ["piper-tts>=1.2", "aiohttp>=3.8", "httpx>=0.24"]
|
|
32
|
+
dev = ["pytest>=7", "aiohttp>=3.8"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ictinnovations/piper-tts-server"
|
|
36
|
+
Repository = "https://github.com/ictinnovations/piper-tts-server"
|
|
37
|
+
"Derived From" = "https://www.ictcontact.com"
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
piper-tts-server = "piper_tts_server.server:run"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools]
|
|
43
|
+
packages = ["piper_tts_server"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from piper_tts_server.tts import resample_linear, iter_frames, pcm_to_wav_bytes, _frame_bytes
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_resample_identity():
|
|
6
|
+
pcm = np.arange(100, dtype=np.int16)
|
|
7
|
+
out = resample_linear(pcm, 8000, 8000)
|
|
8
|
+
assert np.array_equal(out, pcm)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_resample_downsamples_length():
|
|
12
|
+
pcm = np.zeros(2205, dtype=np.int16) # 0.1 s @ 22050
|
|
13
|
+
out = resample_linear(pcm, 22050, 8000)
|
|
14
|
+
assert abs(len(out) - 800) <= 1 # ~0.1 s @ 8000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_frame_bytes():
|
|
18
|
+
assert _frame_bytes(8000, 20) == 320 # 160 samples * 2 bytes
|
|
19
|
+
assert _frame_bytes(16000, 20) == 640
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_iter_frames_pads_tail():
|
|
23
|
+
pcm = np.zeros(170, dtype=np.int16) # 340 bytes -> 2 frames (320 + padded)
|
|
24
|
+
frames = list(iter_frames(pcm, 320))
|
|
25
|
+
assert len(frames) == 2
|
|
26
|
+
assert all(len(f) == 320 for f in frames)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_wav_roundtrip_header():
|
|
30
|
+
pcm = np.zeros(160, dtype=np.int16)
|
|
31
|
+
wav = pcm_to_wav_bytes(pcm, 8000)
|
|
32
|
+
assert wav[:4] == b"RIFF" and wav[8:12] == b"WAVE"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from piper_tts_server.pacing import PacedWriter, make_silence_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_silence_frame_size():
|
|
6
|
+
assert len(make_silence_frame(8000, 20)) == 320
|
|
7
|
+
assert make_silence_frame(8000, 20) == b"\x00" * 320
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_paced_writer_uses_deadline_clock():
|
|
11
|
+
# A virtual clock that does not advance on its own: proves pacing is driven
|
|
12
|
+
# by the deadline, and that a stalled clock never causes negative sleeps.
|
|
13
|
+
t = {"now": 0.0}
|
|
14
|
+
sleeps = []
|
|
15
|
+
|
|
16
|
+
async def sink(_frame):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
async def fake_sleep(d):
|
|
20
|
+
sleeps.append(d)
|
|
21
|
+
t["now"] += d # advance virtual time by the slept amount
|
|
22
|
+
|
|
23
|
+
async def run():
|
|
24
|
+
w = PacedWriter(sink, frame_ms=20, clock=lambda: t["now"])
|
|
25
|
+
orig_sleep = asyncio.sleep
|
|
26
|
+
asyncio.sleep = fake_sleep # type: ignore
|
|
27
|
+
try:
|
|
28
|
+
for _ in range(5):
|
|
29
|
+
await w.write(b"\x00" * 320)
|
|
30
|
+
finally:
|
|
31
|
+
asyncio.sleep = orig_sleep # type: ignore
|
|
32
|
+
return w
|
|
33
|
+
|
|
34
|
+
w = asyncio.get_event_loop().run_until_complete(run())
|
|
35
|
+
assert w.frames_out == 5
|
|
36
|
+
# every scheduled delay is one frame interval, never a burst / negative
|
|
37
|
+
assert all(abs(d - 0.02) < 1e-9 for d in sleeps)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_reset_clears_deadline():
|
|
41
|
+
w = PacedWriter(lambda _f: None, frame_ms=20)
|
|
42
|
+
w._deadline = 123.0
|
|
43
|
+
w.reset()
|
|
44
|
+
assert w._deadline == 0.0
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from piper_tts_server.sanitize import tts_sanitize, split_for_tts
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_strips_markdown_and_emoji():
|
|
5
|
+
assert tts_sanitize("**Hello** _world_ `code`") == "Hello world code"
|
|
6
|
+
assert tts_sanitize("Great news 🎉🚀 done") == "Great news done"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_collapses_whitespace():
|
|
10
|
+
assert tts_sanitize("a\n\n b c\t") == "a b c"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_split_on_sentence_end():
|
|
14
|
+
chunks, rem = split_for_tts("Hello there. How are")
|
|
15
|
+
assert chunks == ["Hello there."]
|
|
16
|
+
assert rem == "How are"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_split_urdu_full_stop():
|
|
20
|
+
chunks, rem = split_for_tts("سلام ۔ ")
|
|
21
|
+
assert chunks == ["سلام ۔"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_force_flush_tail():
|
|
25
|
+
chunks, rem = split_for_tts("no terminator here", force_flush=True)
|
|
26
|
+
assert chunks == ["no terminator here"]
|
|
27
|
+
assert rem == ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_clause_break_lets_synthesis_start_early():
|
|
31
|
+
chunks, rem = split_for_tts("First part, and the rest")
|
|
32
|
+
assert chunks == ["First part,"]
|
|
33
|
+
assert rem == "and the rest"
|