pipecat-backchannel 0.2.0__py3-none-any.whl

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,49 @@
1
+ """Backchannels for Pipecat voice agents.
2
+
3
+ Injects natural continuers ("mhm", "yeah", "right") while the user is still
4
+ talking, so the agent sounds like it's listening instead of waiting. Clips are
5
+ recorded once from your own TTS service and played from disk, so saying "mhm"
6
+ costs no LLM call and no network round trip.
7
+
8
+ Basic usage — no keys, no sample rates, no wiring::
9
+
10
+ from pipecat_backchannel import Backchannel
11
+
12
+ backchannel = Backchannel()
13
+
14
+ pipeline = Pipeline(backchannel([
15
+ transport.input(),
16
+ stt,
17
+ context_aggregator.user(),
18
+ llm,
19
+ tts,
20
+ transport.output(),
21
+ context_aggregator.assistant(),
22
+ ]))
23
+
24
+ This module holds what you need to *use* it. What you need to *change* it lives
25
+ one import deeper:
26
+
27
+ =================================== ========================================
28
+ ``pipecat_backchannel.clips`` the inventory and how a clip is chosen
29
+ ``pipecat_backchannel.synth`` producing clips without the pipeline's TTS
30
+ ``pipecat_backchannel.cache`` where clips are kept between runs
31
+ ``pipecat_backchannel.library`` what clips exist and what they sound like
32
+ ``pipecat_backchannel.placement`` how the processors find their positions
33
+ ``pipecat_backchannel.turn`` the end-of-turn model behind the gate
34
+ ``pipecat_backchannel.processor`` the gate that decides when to fire
35
+ ``pipecat_backchannel.player`` turning decisions into audio
36
+ ``pipecat_backchannel.recorder`` recording clips from the pipeline's TTS
37
+ ``pipecat_backchannel.frames`` the marker frame the two exchange
38
+ =================================== ========================================
39
+ """
40
+
41
+ from pipecat_backchannel.plugin import Backchannel
42
+ from pipecat_backchannel.processor import BackchannelParams
43
+
44
+ __version__ = "0.2.0"
45
+
46
+ __all__ = [
47
+ "Backchannel",
48
+ "BackchannelParams",
49
+ ]
@@ -0,0 +1,114 @@
1
+ """On-disk clip cache.
2
+
3
+ Clips are produced at most once, then read from disk forever after. Nothing here
4
+ runs on the hot path: the cache is filled at pipeline start, and the player only
5
+ ever plays bytes that are already in memory. That's the whole point — no LLM, no
6
+ per-utterance network round trip, no touching the live TTS service's state
7
+ machinery to say "mhm".
8
+
9
+ The :class:`ClipCache` protocol is two methods on purpose. Storage is the least
10
+ interesting decision in this library, and the consumer only ever needs "do you
11
+ have this?" and "keep this".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import wave
18
+ from pathlib import Path
19
+ from typing import Protocol, runtime_checkable
20
+
21
+
22
+ def clip_filename(text: str, sample_rate: int) -> str:
23
+ """Return the cache filename for a clip.
24
+
25
+ Exported so you can pre-record clips by hand: name your ``.wav`` files with
26
+ this, drop them in the cache directory, and they will be used without ever
27
+ calling a synthesizer or recording anything.
28
+
29
+ The name carries a short fingerprint of the exact text, because the readable
30
+ part alone cannot: ``"Hm."``, ``"Hm,"`` and ``"Hm..."`` are three different
31
+ prosodies — the whole point of the inventory — and all collapse to ``hm``.
32
+
33
+ Args:
34
+ text: The clip text, e.g. ``"Mhm."``.
35
+ sample_rate: Sample rate in Hz.
36
+
37
+ Returns:
38
+ A filename such as ``"mhm_5c9e6a_24000hz.wav"``.
39
+ """
40
+ safe = "".join(c if c.isalnum() else "_" for c in text.lower()).strip("_")
41
+ tag = hashlib.sha1(text.encode()).hexdigest()[:6]
42
+ return f"{safe}_{tag}_{sample_rate}hz.wav"
43
+
44
+
45
+ @runtime_checkable
46
+ class ClipCache(Protocol):
47
+ """Keeps clip audio between runs."""
48
+
49
+ def get(self, text: str, sample_rate: int) -> bytes | None:
50
+ """Return cached PCM for a clip, or ``None`` if it isn't stored.
51
+
52
+ Args:
53
+ text: The clip text.
54
+ sample_rate: Sample rate in Hz. Part of the key — clips stored at one
55
+ rate must never be handed back for another.
56
+
57
+ Returns:
58
+ Raw mono 16-bit little-endian PCM, or ``None``.
59
+ """
60
+ ...
61
+
62
+ def put(self, text: str, sample_rate: int, pcm: bytes) -> None:
63
+ """Store PCM for a clip.
64
+
65
+ Args:
66
+ text: The clip text.
67
+ sample_rate: Sample rate in Hz.
68
+ pcm: Raw mono 16-bit little-endian PCM.
69
+ """
70
+ ...
71
+
72
+
73
+ class FileClipCache:
74
+ """Stores clips as ``.wav`` files in a directory.
75
+
76
+ Delete the directory to force everything to be produced again, e.g. after
77
+ changing voice.
78
+ """
79
+
80
+ def __init__(self, directory: str | Path = ".clip_cache"):
81
+ """Initialize the cache.
82
+
83
+ Args:
84
+ directory: Where the ``.wav`` files live. Created if missing.
85
+ """
86
+ self._directory = Path(directory)
87
+
88
+ @property
89
+ def directory(self) -> Path:
90
+ """The directory holding the cached ``.wav`` files."""
91
+ return self._directory
92
+
93
+ def path_for(self, text: str, sample_rate: int) -> Path:
94
+ """Return the full path this cache would use for a clip."""
95
+ return self._directory / clip_filename(text, sample_rate)
96
+
97
+ def get(self, text: str, sample_rate: int) -> bytes | None:
98
+ """Read a clip from disk. See :class:`ClipCache`."""
99
+ path = self.path_for(text, sample_rate)
100
+ if not path.exists():
101
+ return None
102
+ with wave.open(str(path), "rb") as wf:
103
+ pcm = wf.readframes(wf.getnframes())
104
+ return pcm
105
+
106
+ def put(self, text: str, sample_rate: int, pcm: bytes) -> None:
107
+ """Write a clip to disk. See :class:`ClipCache`."""
108
+ self._directory.mkdir(parents=True, exist_ok=True)
109
+ path = self.path_for(text, sample_rate)
110
+ with wave.open(str(path), "wb") as wf:
111
+ wf.setnchannels(1)
112
+ wf.setsampwidth(2)
113
+ wf.setframerate(sample_rate)
114
+ wf.writeframes(pcm)
@@ -0,0 +1,288 @@
1
+ """Backchannel clip inventory and clip-choice strategies.
2
+
3
+ Clip *choice* is a separate, much lower-stakes decision than the fire/no-fire
4
+ gate in :mod:`pipecat_backchannel.processor`: which flavor of "mhm" fits the
5
+ last thing the user said, and don't repeat the same one twice in a row. A wrong
6
+ guess here just sounds slightly off; it never affects *whether* a clip plays.
7
+
8
+ Clips are grouped by conversational function. The default
9
+ :class:`HeuristicClipSelector` picks a group with two regexes over the latest
10
+ ASR partial, then avoids recently-played clips within that group.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ import re
17
+ from collections.abc import Mapping, Sequence
18
+ from typing import Protocol, runtime_checkable
19
+
20
+ from loguru import logger
21
+
22
+ #: Clips grouped by conversational function. Pass a modified copy as
23
+ #: ``Backchannel(clip_groups=...)`` to change the inventory; the audio for
24
+ #: whatever you list is produced at startup.
25
+ #:
26
+ #: Real backchannels are a small, closed set of vocalizations — *uh-huh, yeah,
27
+ #: mm-hmm, right, okay, oh, huh, hm* covers nearly all of them. Assessments like
28
+ #: "absolutely" or "makes sense" read as backchannels on the page but in real
29
+ #: speech they arrive at turn boundaries, not over someone mid-sentence, and a
30
+ #: bot that says them while you're still talking sounds like it's answering.
31
+ #:
32
+ #: So variety comes from **prosody, not vocabulary**: the same few sounds spelled
33
+ #: and punctuated differently, because that is the handle a TTS gives you.
34
+ #: ``"Mhm."`` falls, ``"Mhm,"`` stays up and hands the floor back, ``"Hm..."``
35
+ #: draws out. One sound, three meanings.
36
+ #:
37
+ #: Every group needs at least two clips, or it repeats itself no matter what the
38
+ #: selector does.
39
+ DEFAULT_CLIP_GROUPS: dict[str, list[str]] = {
40
+ # Plain continuer — the safe default, fits almost any mid-thought pause.
41
+ "continue": [
42
+ "Mhm.",
43
+ "Mhm,",
44
+ "Mm-hmm.",
45
+ "Mm-hmm,",
46
+ "Mm.",
47
+ "Mm,",
48
+ "Right.",
49
+ "Okay.",
50
+ ],
51
+ # Agreement/confirmation — fits when they just stated something concrete.
52
+ "affirm": [
53
+ "Yeah.",
54
+ "Yeah,",
55
+ "Yep.",
56
+ "Yup.",
57
+ "Uh-huh, yeah.",
58
+ "Yeah, yeah.",
59
+ "Okay, yeah.",
60
+ ],
61
+ # Hesitation-empathy — fits when they're visibly searching for words. The
62
+ # trailing ellipsis is doing the work: it keeps the sound unresolved, which
63
+ # is what makes it read as thinking along rather than answering.
64
+ "thinking": [
65
+ "Hmm.",
66
+ "Hm.",
67
+ "Hm...",
68
+ "Hmm...",
69
+ "Mm...",
70
+ "Yeah...",
71
+ "Right...",
72
+ ],
73
+ # Realization/surprise — fits when they're introducing something notable.
74
+ # Kept mild on purpose: "wow" and "no way" are strong enough to read as
75
+ # taking the floor, which is the one thing a backchannel must never do.
76
+ "surprise": [
77
+ "Oh.",
78
+ "Oh yeah.",
79
+ "Oh, yeah?",
80
+ "Oh really.",
81
+ "Really.",
82
+ "Huh.",
83
+ "Oh wow.",
84
+ ],
85
+ }
86
+
87
+
88
+ def flatten_clips(groups: Mapping[str, Sequence[str]]) -> list[str]:
89
+ """Flatten grouped clips into the deduplicated list of texts to synthesize.
90
+
91
+ Args:
92
+ groups: Clip groups, e.g. :data:`DEFAULT_CLIP_GROUPS`.
93
+
94
+ Returns:
95
+ Every clip text across all groups, in order, without duplicates.
96
+ """
97
+ seen: dict[str, None] = {}
98
+ for group in groups.values():
99
+ for clip in group:
100
+ seen.setdefault(clip, None)
101
+ return list(seen)
102
+
103
+
104
+ @runtime_checkable
105
+ class ClipSelector(Protocol):
106
+ """Chooses which clip to play for a given moment."""
107
+
108
+ def select(
109
+ self,
110
+ text: str,
111
+ groups: Mapping[str, Sequence[str]],
112
+ recent: Sequence[str],
113
+ ) -> str:
114
+ """Pick a clip.
115
+
116
+ Called at the moment the clip fires, so implementations must not do
117
+ network or disk I/O here.
118
+
119
+ Args:
120
+ text: The latest ASR partial for the user's current turn ("" if none).
121
+ groups: The processor's clip groups.
122
+ recent: Recently played clips, most recent last. Avoid these where
123
+ you can, and avoid ``recent[-1]`` unless the group holds nothing
124
+ else — saying the same thing twice in a row is the only
125
+ repetition a listener reliably notices.
126
+
127
+ Returns:
128
+ A clip text present in ``groups``.
129
+ """
130
+ ...
131
+
132
+
133
+ # Hesitation markers are discourse fillers ("um", "like", "I mean"), not content
134
+ # words — WordNet has no synonym set for them, so this stays a hand list rather
135
+ # than a library expansion.
136
+ _HESITATION_RE = re.compile(
137
+ r"\b(um+|uh+|like|you know|i mean|i don'?t know|not sure|kind of|sort of)\b", re.I
138
+ )
139
+
140
+ _SURPRISE_SEED_WORDS = [
141
+ "surprising",
142
+ "shocking",
143
+ "unexpected",
144
+ "astonishing",
145
+ "remarkable",
146
+ ]
147
+ _SURPRISE_MARKERS = {
148
+ "actually",
149
+ "turns out",
150
+ "apparently",
151
+ "wow",
152
+ "no way",
153
+ "really",
154
+ "honestly",
155
+ }
156
+ # WordNet's similar-adjective graph drifts into unrelated territory from these
157
+ # seeds (e.g. "outrage", "scandalous", "disgraceful" — shock-adjacent, not
158
+ # surprise). Automatic expansion still needs this manual denylist.
159
+ _SURPRISE_DENYLIST = {
160
+ "storm",
161
+ "floor",
162
+ "disgraceful",
163
+ "appal",
164
+ "appall",
165
+ "offend",
166
+ "outrage",
167
+ "scandalise",
168
+ "scandalize",
169
+ "scandalous",
170
+ "shameful",
171
+ "traumatise",
172
+ "traumatize",
173
+ "upset",
174
+ "unthought",
175
+ "unthought-of",
176
+ "unprovided for",
177
+ "unhoped",
178
+ "lurid",
179
+ "singular",
180
+ }
181
+
182
+
183
+ def expand_via_wordnet(
184
+ seed_words: Sequence[str], denylist: set[str] = _SURPRISE_DENYLIST
185
+ ) -> set[str]:
186
+ """Expand content-word seeds through WordNet's synonym/similarity graph.
187
+
188
+ Only worth doing for real content words with real synonym sets. On the first
189
+ call this downloads the WordNet corpus (~10MB, cached under ``~/nltk_data/``);
190
+ if that fails (offline, sandboxed), it falls back to the seeds alone rather
191
+ than raising.
192
+
193
+ Args:
194
+ seed_words: Words to expand from.
195
+ denylist: Words to drop from the result — WordNet drifts, and expansion
196
+ is not fire-and-forget.
197
+
198
+ Returns:
199
+ The expanded word set, minus ``denylist``.
200
+ """
201
+ try:
202
+ from nltk.corpus import wordnet as wn
203
+
204
+ try:
205
+ wn.synsets("test")
206
+ except LookupError:
207
+ import nltk
208
+
209
+ nltk.download("wordnet", quiet=True)
210
+
211
+ words = set()
212
+ for seed in seed_words:
213
+ for synset in wn.synsets(seed):
214
+ for lemma in synset.lemmas():
215
+ words.add(lemma.name().replace("_", " ").lower())
216
+ if synset.pos() == "a":
217
+ for similar in synset.similar_tos():
218
+ for lemma in similar.lemmas():
219
+ words.add(lemma.name().replace("_", " ").lower())
220
+ return words - denylist
221
+ except Exception as e:
222
+ logger.warning(f"WordNet expansion unavailable ({e}), using seed words only")
223
+ return set(seed_words)
224
+
225
+
226
+ class HeuristicClipSelector:
227
+ """Picks a clip group with two regexes over the latest ASR partial.
228
+
229
+ A rough proxy for the real problem (which filler fits this context). Good
230
+ enough because the stakes are low — see the module docstring.
231
+ """
232
+
233
+ def __init__(
234
+ self, *, affirm_probability: float = 0.4, expand_synonyms: bool = True
235
+ ):
236
+ """Initialize the selector.
237
+
238
+ Any synonym expansion happens here, at construction time — never during
239
+ :meth:`select`, which runs at the moment the clip fires.
240
+
241
+ Args:
242
+ affirm_probability: Chance of "affirm" over "continue" when neither
243
+ regex matches.
244
+ expand_synonyms: Expand the surprise vocabulary via WordNet. Costs a
245
+ one-time ~10MB corpus download on first use; set ``False`` to
246
+ use the seed words alone.
247
+ """
248
+ self._affirm_probability = affirm_probability
249
+ surprise_words = set(_SURPRISE_SEED_WORDS)
250
+ if expand_synonyms:
251
+ surprise_words = expand_via_wordnet(_SURPRISE_SEED_WORDS)
252
+ surprise_words |= _SURPRISE_MARKERS
253
+ self._surprise_re = re.compile(
254
+ r"\b(" + "|".join(re.escape(w) for w in sorted(surprise_words)) + r")\b",
255
+ re.I,
256
+ )
257
+
258
+ def pick_group(self, text: str) -> str:
259
+ """Choose a clip group name for the given ASR partial."""
260
+ if self._surprise_re.search(text):
261
+ return "surprise"
262
+ if _HESITATION_RE.search(text):
263
+ return "thinking"
264
+ return "affirm" if random.random() < self._affirm_probability else "continue"
265
+
266
+ def select(
267
+ self,
268
+ text: str,
269
+ groups: Mapping[str, Sequence[str]],
270
+ recent: Sequence[str],
271
+ ) -> str:
272
+ """Pick a clip, avoiding recent ones within the chosen group."""
273
+ group_name = self.pick_group(text)
274
+ # Custom inventories may not define every default group name.
275
+ if group_name not in groups:
276
+ group_name = next(iter(groups))
277
+ group = groups[group_name]
278
+
279
+ candidates = [c for c in group if c not in recent]
280
+ if not candidates:
281
+ # Every clip in this group has been used recently. Widen back to the
282
+ # whole group, but never onto the one that just played: a repeat two
283
+ # clips apart passes unnoticed, back to back never does.
284
+ last = recent[-1] if recent else None
285
+ candidates = [c for c in group if c != last] or list(group)
286
+ clip = random.choice(candidates)
287
+ logger.debug(f"Backchannel: picked {clip!r} (group={group_name!r})")
288
+ return clip
@@ -0,0 +1,17 @@
1
+ """Frames emitted by the backchannel processor."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pipecat.frames.frames import DataFrame
6
+
7
+
8
+ @dataclass
9
+ class PlayCachedClipFrame(DataFrame):
10
+ """Tells the downstream :class:`~pipecat_backchannel.player.ClipPlayer` to
11
+ play a pre-cached backchannel clip.
12
+
13
+ Parameters:
14
+ text: The clip text, used as the key into the loaded clip dict.
15
+ """
16
+
17
+ text: str
@@ -0,0 +1,165 @@
1
+ """The clip library: what clips exist, and what they sound like.
2
+
3
+ One object owns both halves, because they are one fact. Splitting them is how a
4
+ custom inventory silently drifts out of sync with the audio on disk — the
5
+ processor picks a clip that was never produced, and the bot goes quiet with no
6
+ error anywhere.
7
+
8
+ Nothing here needs a sample rate from the caller. The library learns it from the
9
+ pipeline's ``StartFrame`` and keys the cache on it, so clips can never be played
10
+ back at a rate they weren't produced at.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from collections.abc import Mapping, Sequence
17
+
18
+ from loguru import logger
19
+
20
+ from pipecat_backchannel.cache import ClipCache, FileClipCache
21
+ from pipecat_backchannel.clips import DEFAULT_CLIP_GROUPS, flatten_clips
22
+ from pipecat_backchannel.synth import ClipSynthesizer
23
+
24
+
25
+ def _validate(groups: Mapping[str, Sequence[str]]) -> None:
26
+ if not groups:
27
+ raise ValueError("clip_groups is empty — there would be nothing to say.")
28
+ for name, clips in groups.items():
29
+ if not clips:
30
+ raise ValueError(f"clip group {name!r} is empty — remove it or give it clips.")
31
+ for clip in clips:
32
+ if not clip or not clip.strip():
33
+ raise ValueError(f"clip group {name!r} contains an empty clip text.")
34
+
35
+
36
+ class ClipLibrary:
37
+ """The clips a backchannel can play, and the audio behind them.
38
+
39
+ Filled at pipeline start, from whichever source is available: the cache
40
+ first, then a :class:`~pipecat_backchannel.synth.ClipSynthesizer` if one was
41
+ given. With neither, clips stay missing until something calls :meth:`store`
42
+ — which is what :class:`~pipecat_backchannel.recorder.ClipRecorder` does,
43
+ using the pipeline's own TTS service.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ groups: Mapping[str, Sequence[str]] | None = None,
50
+ synthesizer: ClipSynthesizer | None = None,
51
+ cache: ClipCache | None = None,
52
+ ):
53
+ """Initialize the library.
54
+
55
+ Args:
56
+ groups: Clip inventory grouped by conversational function. Defaults
57
+ to :data:`~pipecat_backchannel.clips.DEFAULT_CLIP_GROUPS`.
58
+ synthesizer: Produces clips the cache doesn't have. ``None`` means
59
+ the clips are recorded from the pipeline's TTS instead.
60
+ cache: Where clips are kept between runs. Defaults to
61
+ :class:`~pipecat_backchannel.cache.FileClipCache`.
62
+ """
63
+ groups = DEFAULT_CLIP_GROUPS if groups is None else groups
64
+ _validate(groups)
65
+ self._groups = groups
66
+ self._texts = flatten_clips(groups)
67
+ self._synthesizer = synthesizer
68
+ self._cache = cache or FileClipCache()
69
+
70
+ self._clips: dict[str, bytes] = {}
71
+ self._sample_rate: int | None = None
72
+ self._lock = asyncio.Lock()
73
+
74
+ @property
75
+ def groups(self) -> Mapping[str, Sequence[str]]:
76
+ """The clip inventory, grouped by conversational function."""
77
+ return self._groups
78
+
79
+ @property
80
+ def texts(self) -> list[str]:
81
+ """Every clip text, deduplicated."""
82
+ return list(self._texts)
83
+
84
+ @property
85
+ def synthesizer(self) -> ClipSynthesizer | None:
86
+ """What produces clips the cache doesn't have, if anything."""
87
+ return self._synthesizer
88
+
89
+ @property
90
+ def sample_rate(self) -> int | None:
91
+ """The rate the loaded clips are at, or ``None`` before the first load."""
92
+ return self._sample_rate
93
+
94
+ @property
95
+ def ready(self) -> bool:
96
+ """Whether every clip has audio. The gate stays shut until this is true."""
97
+ return bool(self._sample_rate) and len(self._clips) == len(self._texts)
98
+
99
+ def missing(self) -> list[str]:
100
+ """Clip texts that still have no audio."""
101
+ return [t for t in self._texts if t not in self._clips]
102
+
103
+ def get(self, text: str) -> bytes | None:
104
+ """Return a clip's PCM, or ``None`` if it isn't loaded."""
105
+ return self._clips.get(text)
106
+
107
+ def store(self, text: str, pcm: bytes) -> None:
108
+ """Add a clip's audio and write it to the cache.
109
+
110
+ Args:
111
+ text: The clip text. Must be part of this library's inventory.
112
+ pcm: Raw mono 16-bit little-endian PCM at :attr:`sample_rate`.
113
+
114
+ Raises:
115
+ RuntimeError: Called before the library knows its sample rate.
116
+ KeyError: ``text`` is not in the inventory.
117
+ """
118
+ if self._sample_rate is None:
119
+ raise RuntimeError("ClipLibrary.store() called before load().")
120
+ if text not in self._texts:
121
+ raise KeyError(f"{text!r} is not in this library's clip inventory.")
122
+ self._clips[text] = pcm
123
+ self._cache.put(text, self._sample_rate, pcm)
124
+
125
+ async def load(self, sample_rate: int) -> None:
126
+ """Fill the library at ``sample_rate``, using the cache then a synthesizer.
127
+
128
+ Idempotent: calling it again at the same rate does nothing. Calling it at
129
+ a *different* rate discards what's loaded and reloads, because a clip
130
+ produced at one rate must never be played at another.
131
+
132
+ Missing clips are left missing rather than raising — the library reports
133
+ that through :attr:`ready`, and something else may fill them in later.
134
+ """
135
+ async with self._lock:
136
+ if sample_rate == self._sample_rate:
137
+ if not self.missing():
138
+ return
139
+ else:
140
+ if self._sample_rate is not None:
141
+ logger.debug(
142
+ f"Backchannel clips: sample rate changed "
143
+ f"{self._sample_rate} -> {sample_rate}, reloading"
144
+ )
145
+ self._clips.clear()
146
+ self._sample_rate = sample_rate
147
+
148
+ from_cache = 0
149
+ synthesized = 0
150
+ for text in self.missing():
151
+ pcm = self._cache.get(text, sample_rate)
152
+ if pcm is not None:
153
+ from_cache += 1
154
+ elif self._synthesizer is not None:
155
+ pcm = await self._synthesizer(text, sample_rate)
156
+ self._cache.put(text, sample_rate, pcm)
157
+ synthesized += 1
158
+ if pcm is not None:
159
+ self._clips[text] = pcm
160
+ if synthesized:
161
+ logger.info(
162
+ f"Backchannel: synthesized {synthesized} clip(s) (one-time, cached after)"
163
+ )
164
+ if from_cache:
165
+ logger.debug(f"Backchannel: loaded {from_cache} clip(s) from cache")