livekit-plugins-denoise 0.1.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.
- livekit/plugins/telephony_denoise/__init__.py +32 -0
- livekit/plugins/telephony_denoise/_buffers.py +53 -0
- livekit/plugins/telephony_denoise/_resample.py +257 -0
- livekit/plugins/telephony_denoise/echo_reference.py +159 -0
- livekit/plugins/telephony_denoise/log.py +3 -0
- livekit/plugins/telephony_denoise/neural.py +222 -0
- livekit/plugins/telephony_denoise/processor.py +455 -0
- livekit/plugins/telephony_denoise/py.typed +0 -0
- livekit/plugins/telephony_denoise/version.py +1 -0
- livekit_plugins_denoise-0.1.0.dist-info/METADATA +136 -0
- livekit_plugins_denoise-0.1.0.dist-info/RECORD +14 -0
- livekit_plugins_denoise-0.1.0.dist-info/WHEEL +4 -0
- livekit_plugins_denoise-0.1.0.dist-info/licenses/LICENSE +21 -0
- livekit_plugins_denoise-0.1.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Self-hosted noise and echo suppression for LiveKit SIP telephony."""
|
|
2
|
+
|
|
3
|
+
from livekit.agents import Plugin
|
|
4
|
+
|
|
5
|
+
from .echo_reference import EchoReferenceTap
|
|
6
|
+
from .log import logger
|
|
7
|
+
from .neural import prewarm
|
|
8
|
+
from .processor import DenoiseOptions, Enhancer, TelephonyDenoiser
|
|
9
|
+
from .version import __version__
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DenoiseOptions",
|
|
13
|
+
"EchoReferenceTap",
|
|
14
|
+
"Enhancer",
|
|
15
|
+
"TelephonyDenoiser",
|
|
16
|
+
"__version__",
|
|
17
|
+
"prewarm",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TelephonyDenoisePlugin(Plugin):
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__(__name__, __version__, __package__, logger)
|
|
24
|
+
|
|
25
|
+
def download_files(self) -> None:
|
|
26
|
+
# Fetches the DeepFilterNet3 weights so `lk agent build` bakes them into
|
|
27
|
+
# the image; otherwise the first call of a fresh worker pays for the
|
|
28
|
+
# download on the event loop, inside its first 10 ms frame.
|
|
29
|
+
prewarm()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Plugin.register_plugin(TelephonyDenoisePlugin())
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Small int16 PCM helpers used by the processing pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Int16Buffer:
|
|
9
|
+
"""A FIFO of interleaved int16 samples."""
|
|
10
|
+
|
|
11
|
+
__slots__ = ("_buf",)
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._buf = np.zeros(0, dtype=np.int16)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def size(self) -> int:
|
|
18
|
+
return int(self._buf.size)
|
|
19
|
+
|
|
20
|
+
def append(self, data: np.ndarray) -> None:
|
|
21
|
+
# `np.concatenate(..., dtype=np.int16)` casts same_kind, so an int32
|
|
22
|
+
# caller would be truncated into garbage PCM without a word. Only int16
|
|
23
|
+
# is ever correct here, so insist on it.
|
|
24
|
+
if data.dtype != np.int16:
|
|
25
|
+
raise TypeError(f"expected int16 samples, got {data.dtype}")
|
|
26
|
+
self._buf = np.concatenate((self._buf, data))
|
|
27
|
+
|
|
28
|
+
def take(self, count: int) -> np.ndarray:
|
|
29
|
+
out = self._buf[:count].copy()
|
|
30
|
+
self._buf = self._buf[count:]
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
def clear(self) -> None:
|
|
34
|
+
self._buf = np.zeros(0, dtype=np.int16)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def to_mono(pcm: np.ndarray, src_channels: int) -> np.ndarray:
|
|
38
|
+
"""Downmix interleaved int16 audio when needed."""
|
|
39
|
+
|
|
40
|
+
if src_channels < 1:
|
|
41
|
+
raise ValueError(f"src_channels must be positive, got {src_channels}")
|
|
42
|
+
if src_channels == 1:
|
|
43
|
+
return pcm
|
|
44
|
+
if pcm.size % src_channels:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"{pcm.size} samples is not a whole number of {src_channels}ch frames"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
frames = pcm.reshape(-1, src_channels).astype(np.int32)
|
|
50
|
+
# Round rather than truncate: `astype` rounds toward zero, which biases
|
|
51
|
+
# every downmixed sample toward silence and adds a DC-free crackle.
|
|
52
|
+
mean = frames.sum(axis=1) / src_channels
|
|
53
|
+
return np.rint(mean).astype(np.int16)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Low-latency streaming resampler for the hop to and from the model's 48 kHz.
|
|
2
|
+
|
|
3
|
+
DeepFilterNet3 only runs at 48 kHz, so every call is resampled up and back down
|
|
4
|
+
again. That round trip, not the model, used to dominate end-to-end delay: soxr
|
|
5
|
+
picks its filter from a quality ladder tuned for offline work, where the only
|
|
6
|
+
settings with a real anti-alias filter cost 75-140 ms, and the cheap settings
|
|
7
|
+
have almost no stopband at all.
|
|
8
|
+
|
|
9
|
+
We do not need a general-purpose resampler. We need one good transition, from
|
|
10
|
+
the top of the voice band up to Nyquist, and that filter is short: a Kaiser
|
|
11
|
+
design hitting 80 dB of stopband lands around 10 ms of round-trip delay at
|
|
12
|
+
8 kHz and less above that, which is an order of magnitude better.
|
|
13
|
+
|
|
14
|
+
Delay is forced to a whole number of samples on both sides, so the pipeline
|
|
15
|
+
stays sample-aligned and a caller can reason about it exactly. That alignment
|
|
16
|
+
is what bounds the usable rate pairs: it costs a filter of `2 * L * M + 1` taps
|
|
17
|
+
in the worst case, so rates that are nearly but not exactly related (8000 to
|
|
18
|
+
8001, say) are rejected outright rather than quietly building a filter with
|
|
19
|
+
tens of millions of taps on the audio thread.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from functools import lru_cache
|
|
25
|
+
from math import gcd
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
29
|
+
|
|
30
|
+
# Keep the passband flat to this fraction of the lower Nyquist. At 8 kHz that
|
|
31
|
+
# puts the edge at 3520 Hz, above the 3400 Hz telephony band; at 16 kHz it
|
|
32
|
+
# clears wideband speech.
|
|
33
|
+
_PASSBAND = 0.88
|
|
34
|
+
_STOPBAND_DB = 80.0
|
|
35
|
+
|
|
36
|
+
# Taps per polyphase branch, which is the per-output-sample multiply count.
|
|
37
|
+
# Supported telephony rates land under 100; the cap only fires for rate pairs
|
|
38
|
+
# whose alignment blows the filter up, and those are rejected rather than run.
|
|
39
|
+
_MAX_PHASE_TAPS = 1024
|
|
40
|
+
|
|
41
|
+
# Elements per gather in the general path, to keep the temporary bounded no
|
|
42
|
+
# matter how long a block the caller hands over.
|
|
43
|
+
_CHUNK_ELEMS = 1 << 16
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _kaiser_beta(atten_db: float) -> float:
|
|
47
|
+
if atten_db > 50:
|
|
48
|
+
return 0.1102 * (atten_db - 8.7)
|
|
49
|
+
if atten_db >= 21:
|
|
50
|
+
return 0.5842 * (atten_db - 21) ** 0.4 + 0.07886 * (atten_db - 21)
|
|
51
|
+
return 0.0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _kaiser_size(
|
|
55
|
+
internal_rate: int, pass_hz: float, stop_hz: float, atten_db: float, align: int
|
|
56
|
+
) -> tuple[int, int]:
|
|
57
|
+
"""Length and group delay of the design, without building it.
|
|
58
|
+
|
|
59
|
+
Separate from `_design` so the cost of a rate pair can be checked before
|
|
60
|
+
anything is allocated.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
width = 2 * np.pi * (stop_hz - pass_hz) / internal_rate
|
|
64
|
+
taps = int(np.ceil((atten_db - 8) / (2.285 * width))) + 1
|
|
65
|
+
# Round the delay up to a multiple of `align` so it divides evenly into both
|
|
66
|
+
# rates, and keep the filter symmetric (odd length).
|
|
67
|
+
delay = -(-((taps - 1) // 2 + 1) // align) * align
|
|
68
|
+
return 2 * delay + 1, delay
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lru_cache(maxsize=32)
|
|
72
|
+
def _design(
|
|
73
|
+
internal_rate: int, pass_hz: float, stop_hz: float, atten_db: float, align: int
|
|
74
|
+
):
|
|
75
|
+
"""Kaiser-windowed sinc lowpass whose group delay is a multiple of `align`."""
|
|
76
|
+
|
|
77
|
+
taps, delay = _kaiser_size(internal_rate, pass_hz, stop_hz, atten_db, align)
|
|
78
|
+
|
|
79
|
+
cutoff = 0.5 * (pass_hz + stop_hz) / internal_rate
|
|
80
|
+
k = np.arange(taps) - delay
|
|
81
|
+
h = 2 * cutoff * np.sinc(2 * cutoff * k) * np.kaiser(taps, _kaiser_beta(atten_db))
|
|
82
|
+
return h / h.sum(), delay
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StreamResampler:
|
|
86
|
+
"""Rational resampler that keeps its filter state across blocks.
|
|
87
|
+
|
|
88
|
+
Feed it whatever block sizes arrive; it returns however many output samples
|
|
89
|
+
are ready. `delay` is the constant group delay, in samples at the internal
|
|
90
|
+
rate, and divides evenly into both the input and the output rate.
|
|
91
|
+
|
|
92
|
+
Input must be mono float audio. One instance carries the filter state for
|
|
93
|
+
one stream and is *not* thread-safe; give each stream its own.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, in_rate: int, out_rate: int) -> None:
|
|
97
|
+
in_rate, out_rate = int(in_rate), int(out_rate)
|
|
98
|
+
if in_rate <= 0 or out_rate <= 0:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"sample rates must be positive, got {in_rate} -> {out_rate}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self.in_rate, self.out_rate = in_rate, out_rate
|
|
104
|
+
divisor = gcd(in_rate, out_rate)
|
|
105
|
+
self.L = out_rate // divisor
|
|
106
|
+
self.M = in_rate // divisor
|
|
107
|
+
|
|
108
|
+
# Nothing to do, and designing a real lowpass here would only shave the
|
|
109
|
+
# top of the band off audio that is already at the right rate.
|
|
110
|
+
self.identity = in_rate == out_rate
|
|
111
|
+
if self.identity:
|
|
112
|
+
self.P = 1
|
|
113
|
+
self.delay = 0
|
|
114
|
+
self._history = np.zeros(0, dtype=np.float32)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
nyquist = min(in_rate, out_rate) / 2.0
|
|
118
|
+
align = self.L * self.M
|
|
119
|
+
internal_rate = in_rate * self.L
|
|
120
|
+
taps_len, _ = _kaiser_size(
|
|
121
|
+
internal_rate, _PASSBAND * nyquist, nyquist, _STOPBAND_DB, align
|
|
122
|
+
)
|
|
123
|
+
if -(-taps_len // self.L) > _MAX_PHASE_TAPS:
|
|
124
|
+
raise ValueError(
|
|
125
|
+
f"{in_rate} Hz -> {out_rate} Hz needs {-(-taps_len // self.L)} taps per phase, "
|
|
126
|
+
f"over the {_MAX_PHASE_TAPS} budget; the rates are too close to unrelated. "
|
|
127
|
+
"Pick rates with a larger common divisor."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
taps, self.delay = _design(
|
|
131
|
+
internal_rate, _PASSBAND * nyquist, nyquist, _STOPBAND_DB, align
|
|
132
|
+
)
|
|
133
|
+
taps = taps * self.L # interpolation gain
|
|
134
|
+
|
|
135
|
+
taps = np.concatenate((taps, np.zeros((-len(taps)) % self.L)))
|
|
136
|
+
self.P = len(taps) // self.L
|
|
137
|
+
phases = taps.reshape(self.P, self.L).T
|
|
138
|
+
|
|
139
|
+
# The fast paths feed BLAS from (P, L); the general path gathers rows
|
|
140
|
+
# from (L, P). Only one layout is ever used, so only one is kept.
|
|
141
|
+
if self.M == 1 or self.L == 1:
|
|
142
|
+
self._phases = np.ascontiguousarray(phases.T, dtype=np.float32) # (P, L)
|
|
143
|
+
else:
|
|
144
|
+
self._phases = np.ascontiguousarray(phases, dtype=np.float32) # (L, P)
|
|
145
|
+
|
|
146
|
+
self._history = np.zeros(self.P - 1, dtype=np.float32)
|
|
147
|
+
self._n_in = 0
|
|
148
|
+
self._n_out = 0
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def delay_in(self) -> int:
|
|
152
|
+
"""Group delay counted in input samples."""
|
|
153
|
+
|
|
154
|
+
return 0 if self.identity else self.delay // self.L
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def delay_out(self) -> int:
|
|
158
|
+
"""Group delay counted in output samples."""
|
|
159
|
+
|
|
160
|
+
return 0 if self.identity else self.delay // self.M
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def delay_seconds(self) -> float:
|
|
164
|
+
"""Group delay in seconds. `delay` itself counts internal-rate samples."""
|
|
165
|
+
|
|
166
|
+
return 0.0 if self.identity else self.delay / (self.in_rate * self.L)
|
|
167
|
+
|
|
168
|
+
def process(self, x: np.ndarray) -> np.ndarray:
|
|
169
|
+
"""Resample one block. Returns the samples that are ready, possibly none."""
|
|
170
|
+
|
|
171
|
+
if x.ndim != 1:
|
|
172
|
+
raise ValueError(f"expected mono 1-D audio, got shape {x.shape}")
|
|
173
|
+
if not np.issubdtype(x.dtype, np.floating):
|
|
174
|
+
# Integer PCM would sail through `astype` scaled by 32768 and blow
|
|
175
|
+
# out every downstream stage, so refuse it at the door.
|
|
176
|
+
raise TypeError(f"expected float audio in [-1, 1], got {x.dtype}")
|
|
177
|
+
|
|
178
|
+
x = np.ascontiguousarray(x, dtype=np.float32)
|
|
179
|
+
if self.identity:
|
|
180
|
+
return x.copy()
|
|
181
|
+
if x.size == 0:
|
|
182
|
+
return np.zeros(0, dtype=np.float32)
|
|
183
|
+
|
|
184
|
+
buf = np.concatenate((self._history, x))
|
|
185
|
+
base = self._n_in - (self.P - 1)
|
|
186
|
+
last = self._n_in + x.size - 1
|
|
187
|
+
|
|
188
|
+
# Output n reads input up to index (n*M)//L, so this is the first n we
|
|
189
|
+
# cannot compute yet.
|
|
190
|
+
end = -(-(last + 1) * self.L // self.M)
|
|
191
|
+
if end <= self._n_out:
|
|
192
|
+
self._advance(buf, x.size)
|
|
193
|
+
return np.zeros(0, dtype=np.float32)
|
|
194
|
+
|
|
195
|
+
n = np.arange(self._n_out, end, dtype=np.int64)
|
|
196
|
+
j = n * self.M
|
|
197
|
+
phase = j % self.L
|
|
198
|
+
newest = j // self.L
|
|
199
|
+
|
|
200
|
+
# Every output reads a P-sample window ending at `newest`; the oldest one
|
|
201
|
+
# must still be in `buf`. If this trips, the counters and the history
|
|
202
|
+
# have drifted apart and the audio below would be silently wrong.
|
|
203
|
+
first = int(newest[0]) - base - self.P + 1
|
|
204
|
+
assert first >= 0, f"resampler history underrun: first={first}"
|
|
205
|
+
|
|
206
|
+
windows = sliding_window_view(buf, self.P)[:, ::-1]
|
|
207
|
+
if self.M == 1:
|
|
208
|
+
# Pure upsample: every phase fires for every input sample, so the
|
|
209
|
+
# whole block is one matrix product.
|
|
210
|
+
stop = int(newest[-1]) - base - self.P + 2
|
|
211
|
+
out = (windows[first:stop] @ self._phases).ravel()
|
|
212
|
+
out = out[int(phase[0]) : int(phase[0]) + n.size]
|
|
213
|
+
elif self.L == 1:
|
|
214
|
+
# Pure decimate: one phase, every M-th window.
|
|
215
|
+
last_row = int(newest[-1]) - base - self.P + 1
|
|
216
|
+
out = windows[first : last_row + 1 : self.M] @ self._phases[:, 0]
|
|
217
|
+
else:
|
|
218
|
+
rows = newest - base - self.P + 1
|
|
219
|
+
out = np.empty(n.size, dtype=np.float32)
|
|
220
|
+
# Gathering every window at once is the largest allocation in the
|
|
221
|
+
# class, and it scales with the caller's block length, so cap it.
|
|
222
|
+
step = max(1, _CHUNK_ELEMS // self.P)
|
|
223
|
+
for start in range(0, n.size, step):
|
|
224
|
+
stop = min(start + step, n.size)
|
|
225
|
+
np.einsum(
|
|
226
|
+
"ij,ij->i",
|
|
227
|
+
windows[rows[start:stop]],
|
|
228
|
+
self._phases[phase[start:stop]],
|
|
229
|
+
out=out[start:stop],
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
self._n_out = end
|
|
233
|
+
self._advance(buf, x.size)
|
|
234
|
+
return np.ascontiguousarray(out, dtype=np.float32)
|
|
235
|
+
|
|
236
|
+
def flush(self) -> np.ndarray:
|
|
237
|
+
"""Push silence through to release the tail still inside the filter.
|
|
238
|
+
|
|
239
|
+
For offline use. A live stream should not call this: it appends real
|
|
240
|
+
latency and the next block would land after a gap of silence.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
if self.identity:
|
|
244
|
+
return np.zeros(0, dtype=np.float32)
|
|
245
|
+
return self.process(np.zeros(self.delay_in, dtype=np.float32))
|
|
246
|
+
|
|
247
|
+
def _advance(self, buf: np.ndarray, consumed: int) -> None:
|
|
248
|
+
self._n_in += consumed
|
|
249
|
+
if self.P > 1:
|
|
250
|
+
self._history = buf[buf.size - (self.P - 1) :].copy()
|
|
251
|
+
|
|
252
|
+
# L outputs consume exactly M inputs, so rebasing by that pair keeps the
|
|
253
|
+
# counters bounded on a long call without shifting the phase.
|
|
254
|
+
if self._n_out >= self.L:
|
|
255
|
+
whole = self._n_out // self.L
|
|
256
|
+
self._n_out -= whole * self.L
|
|
257
|
+
self._n_in -= whole * self.M
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Paces outbound agent audio into the echo canceller as its reference."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from livekit import rtc
|
|
10
|
+
from livekit.agents.voice import io
|
|
11
|
+
|
|
12
|
+
from .log import logger
|
|
13
|
+
from .processor import TelephonyDenoiser
|
|
14
|
+
|
|
15
|
+
_LOOKAHEAD_SECONDS = 0.02
|
|
16
|
+
|
|
17
|
+
# Frames waiting to be paced into the canceller. A second of audio is far more
|
|
18
|
+
# than the pacer can fall behind in practice; past that the reference is stale
|
|
19
|
+
# enough to be useless, and holding it would only grow without limit.
|
|
20
|
+
_MAX_QUEUED_FRAMES = 100
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EchoReferenceTap(io.AudioOutput):
|
|
24
|
+
"""Passes agent audio through untouched while copying it to the canceller.
|
|
25
|
+
|
|
26
|
+
Insert it in front of the existing room output, after `AgentSession.start`:
|
|
27
|
+
|
|
28
|
+
session.output.audio = EchoReferenceTap(
|
|
29
|
+
denoiser, next_in_chain=session.output.audio
|
|
30
|
+
)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
denoiser: TelephonyDenoiser,
|
|
36
|
+
*,
|
|
37
|
+
next_in_chain: io.AudioOutput,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(
|
|
40
|
+
label="EchoReferenceTap",
|
|
41
|
+
next_in_chain=next_in_chain,
|
|
42
|
+
capabilities=io.AudioOutputCapabilities(pause=True),
|
|
43
|
+
# Inherit the requirement rather than reporting None, which would
|
|
44
|
+
# tell the session any rate is fine and let TTS reach a sink that
|
|
45
|
+
# cannot resample it.
|
|
46
|
+
sample_rate=next_in_chain.sample_rate,
|
|
47
|
+
)
|
|
48
|
+
assert self.next_in_chain is not None
|
|
49
|
+
self._denoiser = denoiser
|
|
50
|
+
self._queue: asyncio.Queue[rtc.AudioFrame] = asyncio.Queue()
|
|
51
|
+
self._pacer: asyncio.Task[None] | None = None
|
|
52
|
+
self._playout_clock = 0.0
|
|
53
|
+
self._paused = False
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def _sink(self) -> io.AudioOutput:
|
|
57
|
+
# The base class may wrap a bare leaf in a proxy so it can be swapped
|
|
58
|
+
# later, so read the chain rather than caching what was passed in.
|
|
59
|
+
sink = self.next_in_chain
|
|
60
|
+
assert sink is not None
|
|
61
|
+
return sink
|
|
62
|
+
|
|
63
|
+
async def capture_frame(self, frame: rtc.AudioFrame) -> None:
|
|
64
|
+
await super().capture_frame(frame)
|
|
65
|
+
|
|
66
|
+
if self._queue.qsize() >= _MAX_QUEUED_FRAMES:
|
|
67
|
+
# Drop the stalest frame: a reference that late no longer lines up
|
|
68
|
+
# with any echo still arriving.
|
|
69
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
70
|
+
self._queue.get_nowait()
|
|
71
|
+
self._queue.put_nowait(frame)
|
|
72
|
+
|
|
73
|
+
# Only pace while playing. Starting here unconditionally used to undo
|
|
74
|
+
# the `pause()` below on the very next frame.
|
|
75
|
+
if not self._paused:
|
|
76
|
+
self._start_pacer()
|
|
77
|
+
|
|
78
|
+
await self._sink.capture_frame(frame)
|
|
79
|
+
|
|
80
|
+
def flush(self) -> None:
|
|
81
|
+
super().flush()
|
|
82
|
+
self._sink.flush()
|
|
83
|
+
|
|
84
|
+
def clear_buffer(self) -> None:
|
|
85
|
+
# The agent was interrupted, so queued audio will never reach the
|
|
86
|
+
# caller. Feeding it as a reference would make the canceller hunt for an
|
|
87
|
+
# echo that does not exist.
|
|
88
|
+
self._drain()
|
|
89
|
+
self._playout_clock = 0.0
|
|
90
|
+
self._sink.clear_buffer()
|
|
91
|
+
|
|
92
|
+
def pause(self) -> None:
|
|
93
|
+
# Playout has stopped, so the pacer must stop too. Left running it would
|
|
94
|
+
# keep feeding the canceller a reference for audio the caller is not
|
|
95
|
+
# hearing yet, and the echo path would drift by the length of the pause.
|
|
96
|
+
self._paused = True
|
|
97
|
+
self._stop_pacer()
|
|
98
|
+
super().pause()
|
|
99
|
+
|
|
100
|
+
def resume(self) -> None:
|
|
101
|
+
# Restart the virtual clock from the present; queued frames are still
|
|
102
|
+
# pending and now line up with playout resuming.
|
|
103
|
+
self._paused = False
|
|
104
|
+
self._playout_clock = 0.0
|
|
105
|
+
self._start_pacer()
|
|
106
|
+
super().resume()
|
|
107
|
+
|
|
108
|
+
def on_detached(self) -> None:
|
|
109
|
+
self._stop_pacer()
|
|
110
|
+
super().on_detached()
|
|
111
|
+
|
|
112
|
+
async def aclose(self) -> None:
|
|
113
|
+
"""Stop pacing and wait for the task to unwind."""
|
|
114
|
+
|
|
115
|
+
pacer, self._pacer = self._pacer, None
|
|
116
|
+
self._drain()
|
|
117
|
+
if pacer is not None:
|
|
118
|
+
pacer.cancel()
|
|
119
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
120
|
+
await pacer
|
|
121
|
+
|
|
122
|
+
def _start_pacer(self) -> None:
|
|
123
|
+
if self._pacer is None or self._pacer.done():
|
|
124
|
+
self._pacer = asyncio.create_task(self._pace())
|
|
125
|
+
|
|
126
|
+
def _stop_pacer(self) -> None:
|
|
127
|
+
if self._pacer is not None:
|
|
128
|
+
self._pacer.cancel()
|
|
129
|
+
self._pacer = None
|
|
130
|
+
|
|
131
|
+
def _drain(self) -> None:
|
|
132
|
+
while not self._queue.empty():
|
|
133
|
+
self._queue.get_nowait()
|
|
134
|
+
|
|
135
|
+
async def _pace(self) -> None:
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
frame = await self._queue.get()
|
|
139
|
+
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
if self._playout_clock < now:
|
|
142
|
+
# First frame of an utterance, or we fell behind: restart
|
|
143
|
+
# the virtual clock from the present.
|
|
144
|
+
self._playout_clock = now
|
|
145
|
+
|
|
146
|
+
self._denoiser.push_render_frame(frame)
|
|
147
|
+
self._playout_clock += frame.duration
|
|
148
|
+
|
|
149
|
+
# Stay slightly ahead of real time; the canceller wants the
|
|
150
|
+
# reference before the echo arrives, never after.
|
|
151
|
+
sleep_for = self._playout_clock - time.monotonic() - _LOOKAHEAD_SECONDS
|
|
152
|
+
if sleep_for > 0:
|
|
153
|
+
await asyncio.sleep(sleep_for)
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
raise
|
|
156
|
+
except Exception:
|
|
157
|
+
logger.exception(
|
|
158
|
+
"echo reference pacer stopped, echo cancellation will degrade"
|
|
159
|
+
)
|