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.
@@ -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
+ )
@@ -0,0 +1,3 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("livekit.plugins.telephony_denoise")