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.
@@ -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
+ [![CI](https://github.com/ictinnovations/piper-tts-server/actions/workflows/ci.yml/badge.svg)](https://github.com/ictinnovations/piper-tts-server/actions)
42
+ [![PyPI](https://img.shields.io/pypi/v/piper-tts-server.svg)](https://pypi.org/project/piper-tts-server/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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
+ [![CI](https://github.com/ictinnovations/piper-tts-server/actions/workflows/ci.yml/badge.svg)](https://github.com/ictinnovations/piper-tts-server/actions)
6
+ [![PyPI](https://img.shields.io/pypi/v/piper-tts-server.svg)](https://pypi.org/project/piper-tts-server/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,4 @@
1
+ from .server import run
2
+
3
+ if __name__ == "__main__":
4
+ run()
@@ -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
+ [![CI](https://github.com/ictinnovations/piper-tts-server/actions/workflows/ci.yml/badge.svg)](https://github.com/ictinnovations/piper-tts-server/actions)
42
+ [![PyPI](https://img.shields.io/pypi/v/piper-tts-server.svg)](https://pypi.org/project/piper-tts-server/)
43
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,2 @@
1
+ [console_scripts]
2
+ piper-tts-server = piper_tts_server.server:run
@@ -0,0 +1,19 @@
1
+ numpy>=1.21
2
+
3
+ [all]
4
+ piper-tts>=1.2
5
+ aiohttp>=3.8
6
+ httpx>=0.24
7
+
8
+ [dev]
9
+ pytest>=7
10
+ aiohttp>=3.8
11
+
12
+ [elevenlabs]
13
+ httpx>=0.24
14
+
15
+ [piper]
16
+ piper-tts>=1.2
17
+
18
+ [server]
19
+ aiohttp>=3.8
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"