echospeaks 0.0.1__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.
- echospeaks-0.0.1/.gitignore +10 -0
- echospeaks-0.0.1/PKG-INFO +113 -0
- echospeaks-0.0.1/README.md +99 -0
- echospeaks-0.0.1/echovoice/__init__.py +27 -0
- echospeaks-0.0.1/echovoice/cache/__init__.py +10 -0
- echospeaks-0.0.1/echovoice/cache/keying.py +99 -0
- echospeaks-0.0.1/echovoice/cache/store.py +94 -0
- echospeaks-0.0.1/echovoice/common/__init__.py +4 -0
- echospeaks-0.0.1/echovoice/common/log.py +7 -0
- echospeaks-0.0.1/echovoice/common/models.py +39 -0
- echospeaks-0.0.1/echovoice/defaults.py +31 -0
- echospeaks-0.0.1/echovoice/pipecat_tts/__init__.py +33 -0
- echospeaks-0.0.1/echovoice/pipecat_tts/bracket_filter.py +155 -0
- echospeaks-0.0.1/echovoice/pipecat_tts/capture.py +104 -0
- echospeaks-0.0.1/echovoice/pipecat_tts/mixin.py +350 -0
- echospeaks-0.0.1/echovoice/pipecat_tts/pending.py +46 -0
- echospeaks-0.0.1/echovoice/remote/__init__.py +14 -0
- echospeaks-0.0.1/echovoice/remote/client.py +133 -0
- echospeaks-0.0.1/echovoice/remote/config.py +50 -0
- echospeaks-0.0.1/pyproject.toml +37 -0
- echospeaks-0.0.1/tests/test_client_chunking.py +20 -0
- echospeaks-0.0.1/tests/test_configure.py +33 -0
- echospeaks-0.0.1/tests/test_keying.py +37 -0
- echospeaks-0.0.1/tests/test_miss_model.py +13 -0
- echospeaks-0.0.1/tests/test_store.py +95 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: echospeaks
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Sentence-level TTS audio cache for pipecat voice pipelines — in-memory store, cache keying, and EnableTTSCache/TTSCacheCapture pipeline integration
|
|
5
|
+
Author-email: Futwork <om@futwork.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Classifier: License :: Other/Proprietary License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
11
|
+
Provides-Extra: pipecat
|
|
12
|
+
Requires-Dist: pipecat-ai>=1.0.0; extra == 'pipecat'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# echovoice
|
|
16
|
+
|
|
17
|
+
Sentence-level TTS audio cache for [pipecat](https://github.com/pipecat-ai/pipecat)
|
|
18
|
+
voice pipelines. **In-memory only for now** — audio captured on one call is
|
|
19
|
+
served on every later call in the same process; no external service, no
|
|
20
|
+
persistence.
|
|
21
|
+
|
|
22
|
+
## Layout
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
echovoice/
|
|
26
|
+
defaults.py every SDK-wide tunable default, in one place
|
|
27
|
+
common/ shared dataclasses (CachedAudio, AudioMeta) + logger shim
|
|
28
|
+
cache/ keying (normalization + cache keys) and the in-memory store
|
|
29
|
+
pipecat_tts/ pipeline integration — mixin.py (read), capture.py (write),
|
|
30
|
+
pending.py (read→write handoff), bracket_filter.py (merges
|
|
31
|
+
per-sentence Started/Stopped into turn-level brackets so the
|
|
32
|
+
transport behaves like stock); needs pipecat-ai
|
|
33
|
+
remote/ dormant echo-service HTTP path (client + config)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
Two pieces, wired around the host's existing TTS service:
|
|
39
|
+
|
|
40
|
+
- **Read — `EnableTTSCache`** (`echovoice.pipecat_tts.mixin`): drop-in
|
|
41
|
+
replacement for instantiating the provider class. Every sentence's
|
|
42
|
+
`run_tts` first checks the shared in-memory store: a **hit** replays the
|
|
43
|
+
cached PCM as `TTSAudioRawFrame`s (zero provider traffic, barge-in aware),
|
|
44
|
+
a **miss** synthesizes live and marks the sentence for capture.
|
|
45
|
+
- **Write — `TTSCacheCapture`** (`echovoice.pipecat_tts.capture`): a
|
|
46
|
+
pass-through processor placed right after the TTS service in the pipeline.
|
|
47
|
+
It buffers each miss-marked sentence's audio and stores the finished
|
|
48
|
+
sentence in the shared store (`echovoice.cache.store.AUDIO_STORE`, a bounded
|
|
49
|
+
LRU map — 500 entries / 200 MB by default). Interruptions discard partial
|
|
50
|
+
buffers, so truncated audio is never stored.
|
|
51
|
+
|
|
52
|
+
Cache keys (`echovoice.cache.keying`) hash normalized text + provider + voice
|
|
53
|
+
+ model + sample rate, so whitespace/casing never causes a false miss and two
|
|
54
|
+
voices in one process can never serve each other's audio. Normalization is
|
|
55
|
+
for key matching only — synthesis always uses the real text.
|
|
56
|
+
|
|
57
|
+
ElevenLabs specifics handled by the wrapper (validated against the
|
|
58
|
+
multi-context websocket API):
|
|
59
|
+
|
|
60
|
+
- One provider context **per sentence**, closed right after its text is sent
|
|
61
|
+
(close is graceful — the provider flushes the audio, then sends the final).
|
|
62
|
+
- A client-side gate caps simultaneous contexts (default 4 — ElevenLabs kills
|
|
63
|
+
the socket at >5; tune via `EnableTTSCache(cache_config={"max_concurrent_context": ...,
|
|
64
|
+
"sentence_wait_timeout_s": ...})`, all defaults live in `echovoice/defaults.py`).
|
|
65
|
+
Slots free on the context's final, interruption, or websocket reconnect.
|
|
66
|
+
- Cache hits take no context and no gate slot; the replayed context is
|
|
67
|
+
completed explicitly so the sentence serializer never stalls.
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e /path/to/echovoice # core (no pipecat needed)
|
|
73
|
+
pip install -e "/path/to/echovoice[pipecat]" # with the pipecat wrapper
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
With uv, in the host app's `pyproject.toml`:
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
dependencies = ["echovoice"]
|
|
80
|
+
|
|
81
|
+
[tool.uv.sources]
|
|
82
|
+
echovoice = { path = "../echovoice", editable = true }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Usage (fw-aisha style)
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from echovoice.pipecat_tts import EnableTTSCache, TTSCacheCapture
|
|
89
|
+
|
|
90
|
+
# read side — instead of CustomElevenLabsTTSService(...):
|
|
91
|
+
tts = EnableTTSCache(CustomElevenLabsTTSService,
|
|
92
|
+
url="wss://api.in.residency.elevenlabs.io",
|
|
93
|
+
api_key=ELEVENLABS_API_KEY,
|
|
94
|
+
settings=ElevenLabsTTSService.Settings(model=..., voice=...),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# write side + bracket filter — right after the TTS service in the pipeline:
|
|
98
|
+
pipeline = Pipeline([..., tts, TTSCacheCapture(), TTSTurnBracketFilter(),
|
|
99
|
+
transport.output(), ...])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
No configuration needed. The wrapped service exposes
|
|
103
|
+
`get_and_reset_turn_stats()` and `log_cache_summary(call_sid=...)` for call
|
|
104
|
+
logs. Skip both pieces and the provider runs fully stock.
|
|
105
|
+
|
|
106
|
+
`echovoice.remote` holds the HTTP client for a future server-backed cache
|
|
107
|
+
(echo-service); it is dormant — nothing on the active path uses it.
|
|
108
|
+
|
|
109
|
+
## Tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv sync && uv run pytest
|
|
113
|
+
```
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# echovoice
|
|
2
|
+
|
|
3
|
+
Sentence-level TTS audio cache for [pipecat](https://github.com/pipecat-ai/pipecat)
|
|
4
|
+
voice pipelines. **In-memory only for now** — audio captured on one call is
|
|
5
|
+
served on every later call in the same process; no external service, no
|
|
6
|
+
persistence.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
echovoice/
|
|
12
|
+
defaults.py every SDK-wide tunable default, in one place
|
|
13
|
+
common/ shared dataclasses (CachedAudio, AudioMeta) + logger shim
|
|
14
|
+
cache/ keying (normalization + cache keys) and the in-memory store
|
|
15
|
+
pipecat_tts/ pipeline integration — mixin.py (read), capture.py (write),
|
|
16
|
+
pending.py (read→write handoff), bracket_filter.py (merges
|
|
17
|
+
per-sentence Started/Stopped into turn-level brackets so the
|
|
18
|
+
transport behaves like stock); needs pipecat-ai
|
|
19
|
+
remote/ dormant echo-service HTTP path (client + config)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
Two pieces, wired around the host's existing TTS service:
|
|
25
|
+
|
|
26
|
+
- **Read — `EnableTTSCache`** (`echovoice.pipecat_tts.mixin`): drop-in
|
|
27
|
+
replacement for instantiating the provider class. Every sentence's
|
|
28
|
+
`run_tts` first checks the shared in-memory store: a **hit** replays the
|
|
29
|
+
cached PCM as `TTSAudioRawFrame`s (zero provider traffic, barge-in aware),
|
|
30
|
+
a **miss** synthesizes live and marks the sentence for capture.
|
|
31
|
+
- **Write — `TTSCacheCapture`** (`echovoice.pipecat_tts.capture`): a
|
|
32
|
+
pass-through processor placed right after the TTS service in the pipeline.
|
|
33
|
+
It buffers each miss-marked sentence's audio and stores the finished
|
|
34
|
+
sentence in the shared store (`echovoice.cache.store.AUDIO_STORE`, a bounded
|
|
35
|
+
LRU map — 500 entries / 200 MB by default). Interruptions discard partial
|
|
36
|
+
buffers, so truncated audio is never stored.
|
|
37
|
+
|
|
38
|
+
Cache keys (`echovoice.cache.keying`) hash normalized text + provider + voice
|
|
39
|
+
+ model + sample rate, so whitespace/casing never causes a false miss and two
|
|
40
|
+
voices in one process can never serve each other's audio. Normalization is
|
|
41
|
+
for key matching only — synthesis always uses the real text.
|
|
42
|
+
|
|
43
|
+
ElevenLabs specifics handled by the wrapper (validated against the
|
|
44
|
+
multi-context websocket API):
|
|
45
|
+
|
|
46
|
+
- One provider context **per sentence**, closed right after its text is sent
|
|
47
|
+
(close is graceful — the provider flushes the audio, then sends the final).
|
|
48
|
+
- A client-side gate caps simultaneous contexts (default 4 — ElevenLabs kills
|
|
49
|
+
the socket at >5; tune via `EnableTTSCache(cache_config={"max_concurrent_context": ...,
|
|
50
|
+
"sentence_wait_timeout_s": ...})`, all defaults live in `echovoice/defaults.py`).
|
|
51
|
+
Slots free on the context's final, interruption, or websocket reconnect.
|
|
52
|
+
- Cache hits take no context and no gate slot; the replayed context is
|
|
53
|
+
completed explicitly so the sentence serializer never stalls.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e /path/to/echovoice # core (no pipecat needed)
|
|
59
|
+
pip install -e "/path/to/echovoice[pipecat]" # with the pipecat wrapper
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
With uv, in the host app's `pyproject.toml`:
|
|
63
|
+
|
|
64
|
+
```toml
|
|
65
|
+
dependencies = ["echovoice"]
|
|
66
|
+
|
|
67
|
+
[tool.uv.sources]
|
|
68
|
+
echovoice = { path = "../echovoice", editable = true }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage (fw-aisha style)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from echovoice.pipecat_tts import EnableTTSCache, TTSCacheCapture
|
|
75
|
+
|
|
76
|
+
# read side — instead of CustomElevenLabsTTSService(...):
|
|
77
|
+
tts = EnableTTSCache(CustomElevenLabsTTSService,
|
|
78
|
+
url="wss://api.in.residency.elevenlabs.io",
|
|
79
|
+
api_key=ELEVENLABS_API_KEY,
|
|
80
|
+
settings=ElevenLabsTTSService.Settings(model=..., voice=...),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# write side + bracket filter — right after the TTS service in the pipeline:
|
|
84
|
+
pipeline = Pipeline([..., tts, TTSCacheCapture(), TTSTurnBracketFilter(),
|
|
85
|
+
transport.output(), ...])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
No configuration needed. The wrapped service exposes
|
|
89
|
+
`get_and_reset_turn_stats()` and `log_cache_summary(call_sid=...)` for call
|
|
90
|
+
logs. Skip both pieces and the provider runs fully stock.
|
|
91
|
+
|
|
92
|
+
`echovoice.remote` holds the HTTP client for a future server-backed cache
|
|
93
|
+
(echo-service); it is dormant — nothing on the active path uses it.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync && uv run pytest
|
|
99
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
echovoice — sentence-level TTS audio cache for pipecat voice pipelines.
|
|
3
|
+
|
|
4
|
+
Package layout:
|
|
5
|
+
defaults.py every SDK-wide tunable default, in one place
|
|
6
|
+
common/ shared dataclasses (CachedAudio, AudioMeta) + logger shim
|
|
7
|
+
cache/ keying (normalization + cache keys) and the in-memory store
|
|
8
|
+
pipecat_tts/ the pipeline integration: EnableTTSCache (read) and
|
|
9
|
+
TTSCacheCapture (write) — import explicitly, needs pipecat-ai
|
|
10
|
+
remote/ dormant echo-service HTTP path for a future server-backed cache
|
|
11
|
+
|
|
12
|
+
echovoice.pipecat_tts is not re-exported here so the core SDK stays
|
|
13
|
+
import-safe without pipecat installed.
|
|
14
|
+
"""
|
|
15
|
+
from .cache.keying import generate_cache_key, normalize_for_cache, settings_signature
|
|
16
|
+
from .cache.store import AUDIO_STORE, InMemoryAudioStore
|
|
17
|
+
from .common.models import AudioMeta, CachedAudio
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AUDIO_STORE",
|
|
21
|
+
"AudioMeta",
|
|
22
|
+
"CachedAudio",
|
|
23
|
+
"InMemoryAudioStore",
|
|
24
|
+
"generate_cache_key",
|
|
25
|
+
"normalize_for_cache",
|
|
26
|
+
"settings_signature",
|
|
27
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .keying import generate_cache_key, normalize_for_cache, settings_signature
|
|
2
|
+
from .store import AUDIO_STORE, InMemoryAudioStore
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AUDIO_STORE",
|
|
6
|
+
"InMemoryAudioStore",
|
|
7
|
+
"generate_cache_key",
|
|
8
|
+
"normalize_for_cache",
|
|
9
|
+
"settings_signature",
|
|
10
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cache-key text normalization + key generation (shared by all providers).
|
|
3
|
+
|
|
4
|
+
IMPORTANT: normalization is for KEY MATCHING ONLY. Audio is always synthesized
|
|
5
|
+
from the real, punctuated text + real settings — never from the normalized key.
|
|
6
|
+
"""
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
|
|
12
|
+
# ── text normalization ────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
_ZERO_WIDTH = {"", "", "", ""}
|
|
15
|
+
_KEEP_PUNCT = {"?", "!"} # prosodic — keep
|
|
16
|
+
_APOSTROPHES = set("'’‘\"“”`") # drop, no space (contractions)
|
|
17
|
+
|
|
18
|
+
_REPEAT_MARK = re.compile(r"([?!])\1+") # "??" -> "?"
|
|
19
|
+
_SPACE_BEFORE_MARK = re.compile(r"\s+([?!])") # "ok ?" -> "ok?"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_for_cache(text: str) -> str:
|
|
23
|
+
if not text:
|
|
24
|
+
return ""
|
|
25
|
+
text = unicodedata.normalize("NFC", text).lower()
|
|
26
|
+
chars = list(text)
|
|
27
|
+
n = len(chars)
|
|
28
|
+
out = []
|
|
29
|
+
for i, ch in enumerate(chars):
|
|
30
|
+
if ch in _ZERO_WIDTH or ch in _APOSTROPHES:
|
|
31
|
+
continue
|
|
32
|
+
if ch in _KEEP_PUNCT:
|
|
33
|
+
out.append(ch)
|
|
34
|
+
continue
|
|
35
|
+
prev = chars[i - 1] if i > 0 else ""
|
|
36
|
+
nxt = chars[i + 1] if i + 1 < n else ""
|
|
37
|
+
if ch in (",", "."):
|
|
38
|
+
out.append(ch if (prev.isdigit() and nxt.isdigit()) else " ")
|
|
39
|
+
continue
|
|
40
|
+
if ch == "%":
|
|
41
|
+
out.append("%" if prev.isdigit() else " ")
|
|
42
|
+
continue
|
|
43
|
+
cat = unicodedata.category(ch)
|
|
44
|
+
if cat == "Sc": # currency symbol next to a number
|
|
45
|
+
out.append(ch if (prev.isdigit() or nxt.isdigit()) else " ")
|
|
46
|
+
continue
|
|
47
|
+
out.append(" " if cat[0] in ("P", "S") else ch) # keep L/M/N + space
|
|
48
|
+
text = "".join(out)
|
|
49
|
+
text = _REPEAT_MARK.sub(r"\1", text)
|
|
50
|
+
text = _SPACE_BEFORE_MARK.sub(r"\1", text)
|
|
51
|
+
return " ".join(text.split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── settings signature ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
_AUDIO_SETTING_KEYS = frozenset({
|
|
57
|
+
"speed", "stability", "similarity_boost", "style", "use_speaker_boost",
|
|
58
|
+
"language", "controls", "emotion", "pitch", "pronunciation_dict_id",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _canon(v):
|
|
63
|
+
if isinstance(v, float):
|
|
64
|
+
return round(v, 6)
|
|
65
|
+
if isinstance(v, dict):
|
|
66
|
+
return {k: _canon(v[k]) for k in sorted(v)}
|
|
67
|
+
if isinstance(v, (list, tuple)):
|
|
68
|
+
return [_canon(x) for x in v]
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def settings_signature(settings: dict | None) -> str:
|
|
73
|
+
"""Canonical 12-char hash of the audio-affecting settings only."""
|
|
74
|
+
if not settings:
|
|
75
|
+
return ""
|
|
76
|
+
sig = {k: _canon(settings[k]) for k in sorted(settings)
|
|
77
|
+
if k in _AUDIO_SETTING_KEYS and settings.get(k) is not None}
|
|
78
|
+
if not sig:
|
|
79
|
+
return ""
|
|
80
|
+
return hashlib.sha256(json.dumps(sig, sort_keys=True).encode("utf-8")).hexdigest()[:12]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── key generation ────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def generate_cache_key(
|
|
86
|
+
text: str,
|
|
87
|
+
*,
|
|
88
|
+
provider: str,
|
|
89
|
+
voice_id: str,
|
|
90
|
+
model_id: str,
|
|
91
|
+
sample_rate: int = 16000,
|
|
92
|
+
num_channels: int = 1,
|
|
93
|
+
encoding: str = "pcm_s16le",
|
|
94
|
+
settings: dict | None = None,
|
|
95
|
+
) -> str:
|
|
96
|
+
normalized = normalize_for_cache(text)
|
|
97
|
+
sig = settings_signature(settings)
|
|
98
|
+
raw = f"{provider}:{model_id}:{voice_id}:{sample_rate}:{num_channels}:{encoding}:{sig}:{normalized}"
|
|
99
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
In-memory TTS audio store shared by the read side (EnableTTSCache) and the
|
|
3
|
+
write side (TTSCacheCapture).
|
|
4
|
+
|
|
5
|
+
Process-wide by design: a sentence captured on one call is served on every
|
|
6
|
+
later call in the same process. In-memory only for now — no persistence, no
|
|
7
|
+
external service; a Redis/echo-service backend can slot in behind the same
|
|
8
|
+
get/put surface later.
|
|
9
|
+
"""
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
|
|
13
|
+
from ..common.log import logger
|
|
14
|
+
from ..common.models import CachedAudio
|
|
15
|
+
from ..defaults import STORE_MAX_BYTES, STORE_MAX_ENTRIES
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InMemoryAudioStore:
|
|
19
|
+
"""Bounded LRU map: cache key -> CachedAudio. Newest capture wins."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, max_entries: int = STORE_MAX_ENTRIES,
|
|
22
|
+
max_bytes: int = STORE_MAX_BYTES):
|
|
23
|
+
self._max_entries = max_entries
|
|
24
|
+
self._max_bytes = max_bytes
|
|
25
|
+
self._entries: "OrderedDict[str, CachedAudio]" = OrderedDict()
|
|
26
|
+
self._total_bytes = 0
|
|
27
|
+
# Process-wide lookup counters, recorded by the read side (note_hit /
|
|
28
|
+
# note_miss) so a rate-mismatched lookup counts as a miss, not a hit.
|
|
29
|
+
self.total_hits = 0
|
|
30
|
+
self.total_misses = 0
|
|
31
|
+
|
|
32
|
+
def get(self, key: str) -> CachedAudio | None:
|
|
33
|
+
entry = self._entries.get(key)
|
|
34
|
+
if entry is not None:
|
|
35
|
+
self._entries.move_to_end(key) # LRU touch
|
|
36
|
+
return entry
|
|
37
|
+
|
|
38
|
+
def put(self, entry: CachedAudio) -> None:
|
|
39
|
+
if not entry.key or not entry.audio:
|
|
40
|
+
return
|
|
41
|
+
old = self._entries.pop(entry.key, None)
|
|
42
|
+
if old is not None:
|
|
43
|
+
self._total_bytes -= len(old.audio)
|
|
44
|
+
entry.hits += old.hits # re-capture keeps the sentence's serve count
|
|
45
|
+
if not entry.captured_at:
|
|
46
|
+
entry.captured_at = time.time()
|
|
47
|
+
self._entries[entry.key] = entry
|
|
48
|
+
self._total_bytes += len(entry.audio)
|
|
49
|
+
while self._entries and (
|
|
50
|
+
len(self._entries) > self._max_entries or self._total_bytes > self._max_bytes
|
|
51
|
+
):
|
|
52
|
+
evicted_key, evicted = self._entries.popitem(last=False)
|
|
53
|
+
self._total_bytes -= len(evicted.audio)
|
|
54
|
+
logger.debug(f"TTS_CACHE evicted key={evicted_key[:12]} bytes={len(evicted.audio)}")
|
|
55
|
+
|
|
56
|
+
def delete(self, key: str) -> bool:
|
|
57
|
+
"""Remove one entry (e.g. via the host's inspection endpoint, to
|
|
58
|
+
re-create miss scenarios). Returns True if the entry existed."""
|
|
59
|
+
entry = self._entries.pop(key, None)
|
|
60
|
+
if entry is None:
|
|
61
|
+
return False
|
|
62
|
+
self._total_bytes -= len(entry.audio)
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def note_hit(self, entry: CachedAudio) -> None:
|
|
66
|
+
"""Record that this entry was served from the cache."""
|
|
67
|
+
entry.hits += 1
|
|
68
|
+
self.total_hits += 1
|
|
69
|
+
|
|
70
|
+
def note_miss(self) -> None:
|
|
71
|
+
"""Record a lookup that ended in live synthesis."""
|
|
72
|
+
self.total_misses += 1
|
|
73
|
+
|
|
74
|
+
def snapshot(self) -> list[CachedAudio]:
|
|
75
|
+
"""Current entries, most recently used/stored first. Read-only view for
|
|
76
|
+
inspection endpoints/debugging — does not touch LRU order."""
|
|
77
|
+
return list(reversed(self._entries.values()))
|
|
78
|
+
|
|
79
|
+
def clear(self) -> None:
|
|
80
|
+
self._entries.clear()
|
|
81
|
+
self._total_bytes = 0
|
|
82
|
+
self.total_hits = 0
|
|
83
|
+
self.total_misses = 0
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return len(self._entries)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def total_bytes(self) -> int:
|
|
90
|
+
return self._total_bytes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# The process-wide store both pipeline pieces use.
|
|
94
|
+
AUDIO_STORE = InMemoryAudioStore()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Shared dataclasses used across the SDK."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class CachedAudio:
|
|
7
|
+
"""One cached sentence: the PCM plus enough metadata to replay it."""
|
|
8
|
+
|
|
9
|
+
key: str # full cache key (provider/voice/model/rate/text)
|
|
10
|
+
text: str # raw sentence text, kept for logs/debugging only
|
|
11
|
+
audio: bytes # PCM s16le
|
|
12
|
+
sample_rate: int
|
|
13
|
+
num_channels: int = 1
|
|
14
|
+
captured_at: float = 0.0
|
|
15
|
+
hits: int = 0 # times this entry was served (see store.note_hit)
|
|
16
|
+
# Word timings captured off the live synthesis, as (word, seconds from
|
|
17
|
+
# sentence start). Replayed on a hit so the transcript/context is built
|
|
18
|
+
# word-by-word exactly like a live sentence; None when never captured.
|
|
19
|
+
word_times: list | None = None
|
|
20
|
+
words_include_spaces: bool = False # TTSTextFrame.includes_inter_frame_spaces
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def duration_s(self) -> float:
|
|
24
|
+
return len(self.audio) / (max(self.sample_rate, 1) * 2 * max(self.num_channels, 1))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AudioMeta:
|
|
29
|
+
"""Synthesis parameters shipped with a generation job so echo-service can
|
|
30
|
+
re-create the audio server-side (the cache key is a hash — not reversible).
|
|
31
|
+
Used only by the dormant remote (echo-service) path."""
|
|
32
|
+
|
|
33
|
+
provider: str
|
|
34
|
+
voice_id: str
|
|
35
|
+
model_id: str
|
|
36
|
+
sample_rate: int
|
|
37
|
+
num_channels: int = 1
|
|
38
|
+
encoding: str = "pcm_s16le"
|
|
39
|
+
settings: dict = field(default_factory=dict)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Every SDK-wide tunable default in one place.
|
|
3
|
+
|
|
4
|
+
Hosts override the gate/registry values per instance via
|
|
5
|
+
EnableTTSCache(cache_config={...}) — see echovoice.pipecat_tts.mixin. Any key
|
|
6
|
+
missing from cache_config falls back to the value here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# ── context gate (echovoice.pipecat_tts.mixin) ────────────────────────────────
|
|
10
|
+
# ElevenLabs allows at most 5 simultaneous contexts per websocket; exceeding it
|
|
11
|
+
# is a 1008 policy violation that kills the whole socket. Stay one below.
|
|
12
|
+
MAX_CONCURRENT_CONTEXT = 4
|
|
13
|
+
|
|
14
|
+
# Longest a sentence waits at a full gate. A context's final normally lands
|
|
15
|
+
# 150-300ms after close; if no slot has freed in this long, assume the finals
|
|
16
|
+
# were lost (e.g. socket died) and reclaim every slot.
|
|
17
|
+
SENTENCE_WAIT_TIMEOUT_S = 3.0
|
|
18
|
+
|
|
19
|
+
# ── hit replay (echovoice.pipecat_tts.mixin) ──────────────────────────────────
|
|
20
|
+
# Cached PCM is replayed in slices of this many bytes (~30ms @ 16kHz mono
|
|
21
|
+
# s16le) so a barge-in can stop the replay between slices.
|
|
22
|
+
REPLAY_CHUNK_BYTES = 960
|
|
23
|
+
|
|
24
|
+
# ── pending-capture registry (echovoice.pipecat_tts.pending) ──────────────────
|
|
25
|
+
# Bound on the read→write handoff registry; marks whose contexts never finish
|
|
26
|
+
# (dead socket, interruption races) age out past this many entries.
|
|
27
|
+
PENDING_MAX_ENTRIES = 512
|
|
28
|
+
|
|
29
|
+
# ── in-memory audio store (echovoice.cache.store) ─────────────────────────────
|
|
30
|
+
STORE_MAX_ENTRIES = 500
|
|
31
|
+
STORE_MAX_BYTES = 200 * 1024 * 1024
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sentence-level TTS cache for pipecat pipelines. In-memory, two pieces:
|
|
3
|
+
|
|
4
|
+
READ — EnableTTSCache(ProviderClass, ...) (mixin.py): drop-in replacement
|
|
5
|
+
for ProviderClass(...). Hits replay cached PCM with zero provider
|
|
6
|
+
traffic; misses synthesize live and are marked for capture.
|
|
7
|
+
|
|
8
|
+
WRITE — TTSCacheCapture() (capture.py): a pass-through processor placed right
|
|
9
|
+
after the TTS service in the pipeline; stores each miss-marked
|
|
10
|
+
sentence's finished audio in the shared store.
|
|
11
|
+
|
|
12
|
+
Host integration is two lines:
|
|
13
|
+
|
|
14
|
+
tts = EnableTTSCache(CustomElevenLabsTTSService, url=..., api_key=..., settings=...)
|
|
15
|
+
pipeline = Pipeline([..., tts, TTSCacheCapture(), transport.output(), ...])
|
|
16
|
+
|
|
17
|
+
Requires pipecat-ai (install extra: echovoice[pipecat]); the rest of the SDK
|
|
18
|
+
imports without it.
|
|
19
|
+
"""
|
|
20
|
+
from ..defaults import MAX_CONCURRENT_CONTEXT, SENTENCE_WAIT_TIMEOUT_S
|
|
21
|
+
from .bracket_filter import TTSTurnBracketFilter
|
|
22
|
+
from .capture import TTSCacheCapture
|
|
23
|
+
from .mixin import EnableTTSCache, TTSCacheMixin, make_cached
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EnableTTSCache",
|
|
27
|
+
"MAX_CONCURRENT_CONTEXT",
|
|
28
|
+
"SENTENCE_WAIT_TIMEOUT_S",
|
|
29
|
+
"TTSCacheCapture",
|
|
30
|
+
"TTSCacheMixin",
|
|
31
|
+
"TTSTurnBracketFilter",
|
|
32
|
+
"make_cached",
|
|
33
|
+
]
|