pipecat-effects 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,39 @@
1
+ """Output audio effects for pipecat. Filter, effects, meter."""
2
+
3
+ from pipecat_effects.effects import (
4
+ AGC,
5
+ Apply,
6
+ Biquad,
7
+ Compressor,
8
+ DeEsser,
9
+ Effect,
10
+ Effects,
11
+ Gain,
12
+ Limiter,
13
+ Reverb,
14
+ Saturation,
15
+ )
16
+ from pipecat_effects.filter import EffectsFilter
17
+ from pipecat_effects.meter import Meter, Reading
18
+ from pipecat_effects.mixer import FilterMixer
19
+ from pipecat_effects.primitives import Kind, Samples
20
+
21
+ __all__ = [
22
+ "AGC",
23
+ "Apply",
24
+ "Biquad",
25
+ "Compressor",
26
+ "DeEsser",
27
+ "Effect",
28
+ "Effects",
29
+ "EffectsFilter",
30
+ "FilterMixer",
31
+ "Gain",
32
+ "Kind",
33
+ "Limiter",
34
+ "Meter",
35
+ "Reading",
36
+ "Reverb",
37
+ "Samples",
38
+ "Saturation",
39
+ ]
@@ -0,0 +1,270 @@
1
+ """Eight effects. Each one validates values and builds one apply call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Protocol
9
+
10
+ import numpy as np
11
+
12
+ from pipecat_effects.primitives import KINDS, Envelope, Kind, Line, Loudness, Samples, Section, row
13
+
14
+ type Apply = Callable[[Samples], Samples]
15
+
16
+ FLOOR = 1e-9 # linear level that reads as silence
17
+ HOLD_LUFS = -50.0 # under this loudness, gain holds
18
+ WINDOW_MS = 400.0 # loudness window, automatic gain
19
+ COMBS = (1116, 1188, 1277, 1356) # Schroeder delays in samples at 44100 Hz
20
+ ALLPASS = (556, 441)
21
+ ALLPASS_FEEDBACK = 0.5
22
+ SCHROEDER_RATE = 44100
23
+
24
+
25
+ class Effect(Protocol):
26
+ """One stage. start gives one apply call."""
27
+
28
+ def start(self, rate: int) -> Apply:
29
+ """Builds primitives of this stage."""
30
+ ...
31
+
32
+
33
+ type Effects = Sequence[Effect]
34
+
35
+
36
+ @dataclass(frozen=True, slots=True, kw_only=True)
37
+ class Gain:
38
+ """One multiply."""
39
+
40
+ db: float = 0.0
41
+
42
+ def __post_init__(self) -> None:
43
+ _within("db", self.db, -60.0, 24.0)
44
+
45
+ def start(self, rate: int) -> Apply:
46
+ """Gives one multiply."""
47
+ factor = np.float32(_linear(self.db))
48
+ return lambda x: x * factor
49
+
50
+
51
+ @dataclass(frozen=True, slots=True, kw_only=True)
52
+ class Biquad:
53
+ """One second-order section from the Audio EQ Cookbook."""
54
+
55
+ kind: Kind
56
+ hz: float
57
+ q: float = 0.7071
58
+ gain_db: float = 0.0
59
+
60
+ def __post_init__(self) -> None:
61
+ if self.kind not in KINDS:
62
+ raise ValueError(f"kind: expected one of {KINDS}, got a name outside them")
63
+ _within("hz", self.hz, 10.0, 20000.0)
64
+ _within("q", self.q, 0.1, 20.0)
65
+ _within("gain_db", self.gain_db, -24.0, 24.0)
66
+
67
+ def start(self, rate: int) -> Apply:
68
+ """Gives one section run call."""
69
+ return Section(self.row(rate)).run
70
+
71
+ def row(self, rate: int) -> tuple[float, ...]:
72
+ """Gives the 6 coefficients of this section at this rate."""
73
+ return row(self.kind, rate=rate, hz=self.hz, q=self.q, gain_db=self.gain_db)
74
+
75
+
76
+ @dataclass(frozen=True, slots=True, kw_only=True)
77
+ class Saturation:
78
+ """Memoryless tanh waveshaper with a dry/wet mix."""
79
+
80
+ drive: float = 2.0
81
+ mix: float = 1.0
82
+
83
+ def __post_init__(self) -> None:
84
+ _within("drive", self.drive, 0.1, 20.0)
85
+ _within("mix", self.mix, 0.0, 1.0)
86
+
87
+ def start(self, rate: int) -> Apply:
88
+ """Gives one waveshaper call, unity at full scale."""
89
+ drive = np.float32(self.drive)
90
+ wet = np.float32(self.mix / math.tanh(self.drive))
91
+ dry = np.float32(1.0 - self.mix)
92
+ return lambda x: dry * x + wet * np.tanh(drive * x)
93
+
94
+
95
+ @dataclass(frozen=True, slots=True, kw_only=True)
96
+ class Compressor:
97
+ """One envelope, gain over threshold by ratio, then makeup."""
98
+
99
+ threshold_db: float = -18.0
100
+ ratio: float = 3.0
101
+ attack_ms: float = 5.0
102
+ release_ms: float = 80.0
103
+ makeup_db: float = 0.0
104
+
105
+ def __post_init__(self) -> None:
106
+ _within("threshold_db", self.threshold_db, -60.0, 0.0)
107
+ _within("ratio", self.ratio, 1.0, 20.0)
108
+ _within("attack_ms", self.attack_ms, 0.0, 200.0)
109
+ _within("release_ms", self.release_ms, 1.0, 2000.0)
110
+ _within("makeup_db", self.makeup_db, -24.0, 24.0)
111
+
112
+ def start(self, rate: int) -> Apply:
113
+ """Follows level, applies gain."""
114
+ level = Envelope(rate=rate, attack_ms=self.attack_ms, release_ms=self.release_ms)
115
+ slope = 1.0 - 1.0 / self.ratio
116
+ threshold, makeup = self.threshold_db, self.makeup_db
117
+
118
+ def apply(x: Samples) -> Samples:
119
+ over = np.maximum(_decibels(level.run(np.abs(x))) - threshold, 0.0)
120
+ return x * _gain(makeup - slope * over)
121
+
122
+ return apply
123
+
124
+
125
+ @dataclass(frozen=True, slots=True, kw_only=True)
126
+ class AGC:
127
+ """Momentary K-weighted loudness to target, at bounded rate."""
128
+
129
+ target_lufs: float = -20.0
130
+ max_db_per_second: float = 6.0
131
+
132
+ def __post_init__(self) -> None:
133
+ _within("target_lufs", self.target_lufs, -40.0, -10.0)
134
+ _within("max_db_per_second", self.max_db_per_second, 0.1, 20.0)
135
+
136
+ def start(self, rate: int) -> Apply:
137
+ """Measures loudness, ramps gain."""
138
+ loudness = Loudness(rate=rate, window_ms=WINDOW_MS)
139
+ gain = Envelope(
140
+ rate=rate, attack_ms=0.0, release_ms=0.0, max_per_second=self.max_db_per_second
141
+ )
142
+ target = self.target_lufs
143
+
144
+ def apply(x: Samples) -> Samples:
145
+ level = loudness.run(x)
146
+ wanted = gain.value if level < HOLD_LUFS else target - level
147
+ return x * _gain(gain.run(np.full(x.size, wanted, dtype=np.float32)))
148
+
149
+ return apply
150
+
151
+
152
+ @dataclass(frozen=True, slots=True, kw_only=True)
153
+ class Limiter:
154
+ """Memoryless soft clipper. Soft knee under the ceiling, hard ceiling at it."""
155
+
156
+ ceiling_db: float = -1.0
157
+ knee_db: float = 3.0
158
+
159
+ def __post_init__(self) -> None:
160
+ _within("ceiling_db", self.ceiling_db, -24.0, 0.0)
161
+ _within("knee_db", self.knee_db, 0.0, 12.0)
162
+
163
+ def start(self, rate: int) -> Apply:
164
+ """Memoryless curve, 0 samples over the ceiling."""
165
+ ceiling = np.float32(_linear(self.ceiling_db))
166
+ knee = np.float32(_linear(self.ceiling_db - self.knee_db))
167
+ width = np.float32(2.0 * (ceiling - knee))
168
+ span = np.float32(4.0 * (ceiling - knee)) if self.knee_db > 0.0 else np.float32(1.0)
169
+
170
+ def apply(x: Samples) -> Samples:
171
+ magnitude = np.abs(x)
172
+ over = np.clip(magnitude - knee, 0.0, width)
173
+ return np.copysign(np.minimum(magnitude, knee + over - over * over / span), x)
174
+
175
+ return apply
176
+
177
+
178
+ @dataclass(frozen=True, slots=True, kw_only=True)
179
+ class Reverb:
180
+ """Schroeder reverberator. Four comb filters, 2 allpass filters."""
181
+
182
+ decay_ms: float = 200.0
183
+ mix: float = 0.15
184
+
185
+ def __post_init__(self) -> None:
186
+ _within("decay_ms", self.decay_ms, 10.0, 500.0)
187
+ _within("mix", self.mix, 0.0, 1.0)
188
+
189
+ def start(self, rate: int) -> Apply:
190
+ """Sums combs, runs the allpass pair."""
191
+ combs = [
192
+ Line(samples=n, feedback=self._feedback(n, rate)) for n in self._delays(COMBS, rate)
193
+ ]
194
+ allpass = [
195
+ Line(samples=n, feedback=ALLPASS_FEEDBACK, allpass=True)
196
+ for n in self._delays(ALLPASS, rate)
197
+ ]
198
+ wet, dry = np.float32(self.mix / len(combs)), np.float32(1.0 - self.mix)
199
+
200
+ def apply(x: Samples) -> Samples:
201
+ tail = np.stack([comb.run(x) for comb in combs]).sum(axis=0) * wet
202
+ for section in allpass:
203
+ tail = section.run(tail)
204
+ return dry * x + tail
205
+
206
+ return apply
207
+
208
+ def _delays(self, delays: tuple[int, ...], rate: int) -> list[int]:
209
+ """Scales Schroeder delays to this rate."""
210
+ return [max(round(delay * rate / SCHROEDER_RATE), 1) for delay in delays]
211
+
212
+ def _feedback(self, samples: int, rate: int) -> float:
213
+ """Gives feedback that falls 60 dB in decay_ms."""
214
+ return 10.0 ** (-3000.0 * samples / (rate * self.decay_ms))
215
+
216
+
217
+ @dataclass(frozen=True, slots=True, kw_only=True)
218
+ class DeEsser:
219
+ """Split-band de-esser. The envelope of one band-pass band sets the cut of that band."""
220
+
221
+ hz: float = 6500.0
222
+ q: float = 1.5
223
+ threshold_db: float = -30.0
224
+ ratio: float = 4.0
225
+ attack_ms: float = 1.0
226
+ release_ms: float = 40.0
227
+
228
+ def __post_init__(self) -> None:
229
+ _within("hz", self.hz, 1000.0, 20000.0)
230
+ _within("q", self.q, 0.1, 20.0)
231
+ _within("threshold_db", self.threshold_db, -60.0, 0.0)
232
+ _within("ratio", self.ratio, 1.0, 20.0)
233
+ _within("attack_ms", self.attack_ms, 0.0, 200.0)
234
+ _within("release_ms", self.release_ms, 1.0, 2000.0)
235
+
236
+ def start(self, rate: int) -> Apply:
237
+ """Takes the band down over threshold."""
238
+ band = Section.at("bandpass", rate=rate, hz=self.hz, q=self.q)
239
+ level = Envelope(rate=rate, attack_ms=self.attack_ms, release_ms=self.release_ms)
240
+ slope = 1.0 - 1.0 / self.ratio
241
+ threshold = self.threshold_db
242
+
243
+ def apply(x: Samples) -> Samples:
244
+ found = band.run(x)
245
+ over = np.maximum(_decibels(level.run(np.abs(found))) - threshold, 0.0)
246
+ return x - (1.0 - _gain(-slope * over)) * found
247
+
248
+ return apply
249
+
250
+
251
+ def _within(field: str, value: float, low: float, high: float) -> None:
252
+ """Refuses one value outside its range."""
253
+ if not low <= value <= high:
254
+ side = "under" if value < low else "over"
255
+ raise ValueError(f"{field}: expected {low} to {high}, got a value {side} the range")
256
+
257
+
258
+ def _linear(db: float) -> float:
259
+ """Gives one amplitude from dB."""
260
+ return 10.0 ** (db / 20.0)
261
+
262
+
263
+ def _gain(db: Samples | float) -> Samples:
264
+ """Gives one amplitude per dB level."""
265
+ return 10.0 ** (np.asarray(db, dtype=np.float32) / 20.0)
266
+
267
+
268
+ def _decibels(level: Samples) -> Samples:
269
+ """Gives one dB level per amplitude."""
270
+ return 20.0 * np.log10(np.maximum(level, FLOOR))
@@ -0,0 +1,139 @@
1
+ """Output audio filter. One chain per session, run as given."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ from pipecat.audio.filters.base_audio_filter import BaseAudioFilter
10
+ from pipecat.frames.frames import FilterControlFrame, FilterEnableFrame, FilterUpdateSettingsFrame
11
+
12
+ from pipecat_effects.effects import Apply, Biquad, Effects
13
+ from pipecat_effects.primitives import Samples, Section
14
+
15
+ SCALE = 32768.0 # int16 full scale
16
+ TOP = 32767 # highest int16 sample
17
+ SETTING = "effects" # update frame key of a new chain
18
+
19
+
20
+ class EffectsFilter(BaseAudioFilter):
21
+ """Processes spoken audio. It adds 0 samples of latency and runs on mono."""
22
+
23
+ def __init__(self, effects: Effects) -> None:
24
+ self._effects: Effects = tuple(effects)
25
+ self._rate = 0
26
+ self._chain: list[Apply] = []
27
+ self._heard: list[Apply] | None = None # the chain of the last chunk, None for the input
28
+ self._enabled = True
29
+
30
+ async def start(self, sample_rate: int) -> None:
31
+ """Builds the chain at this rate."""
32
+ self._rate = sample_rate
33
+ self._chain = _started(self._effects, sample_rate)
34
+ self._heard = self._chain if self._enabled else None
35
+
36
+ async def stop(self) -> None:
37
+ """Drops the chain."""
38
+ self._chain, self._heard, self._rate = [], None, 0
39
+
40
+ async def process_frame(self, frame: FilterControlFrame) -> None:
41
+ """Enable frame switches the bypass. Settings frame rebuilds the chain."""
42
+ if isinstance(frame, FilterEnableFrame):
43
+ self._enabled = frame.enable
44
+ elif isinstance(frame, FilterUpdateSettingsFrame):
45
+ self._update(frame.settings)
46
+
47
+ async def filter(self, audio: bytes) -> bytes:
48
+ """Runs one mono int16 chunk, silence included. Gives int16 bytes."""
49
+ taken = decoded(audio)
50
+ if not taken.size:
51
+ return audio
52
+ given = self._chunk(taken)
53
+ return np.clip(np.rint(given * SCALE), -SCALE, TOP).astype(np.int16).tobytes()
54
+
55
+ def _chunk(self, x: Samples) -> Samples:
56
+ """Runs the chain, in bypass too. A change fades from the output last heard."""
57
+ wet = _through(self._chain, x)
58
+ before, after = self._before(x, wet), (wet if self._enabled else x)
59
+ self._heard = self._chain if self._enabled else None
60
+ return after if before is after else _faded(before, after)
61
+
62
+ def _before(self, x: Samples, wet: Samples) -> Samples:
63
+ """Gives this chunk through the chain last heard. A bypass heard the input."""
64
+ if self._heard is None:
65
+ return x
66
+ return wet if self._heard is self._chain else _through(self._heard, x)
67
+
68
+ def _update(self, settings: Mapping[str, Any]) -> None:
69
+ """Builds the new chain, then swaps it in. A failed build keeps the old chain."""
70
+ if (payload := settings.get(SETTING)) is None:
71
+ return
72
+ effects = _sequence(payload)
73
+ if self._rate:
74
+ try:
75
+ self._chain = _started(effects, self._rate)
76
+ except ValueError as error:
77
+ raise ValueError(f"{SETTING}: {error}") from error
78
+ except TypeError as error:
79
+ raise TypeError(f"{SETTING}: {error}") from error
80
+ self._effects = effects
81
+
82
+
83
+ def decoded(audio: bytes) -> Samples:
84
+ """Gives one int16 chunk as float32 samples, full scale at 1.0. An odd byte count raises."""
85
+ if len(audio) % 2:
86
+ raise ValueError(f"audio: expected int16 bytes, an even count, got {len(audio)} bytes")
87
+ return np.frombuffer(audio, dtype=np.int16).astype(np.float32) / SCALE
88
+
89
+
90
+ def _started(effects: Effects, rate: int) -> list[Apply]:
91
+ """Starts each effect. Adjacent biquads run as one cascade in one sosfilt call."""
92
+ chain: list[Apply] = []
93
+ biquads: list[Biquad] = []
94
+ for index, effect in enumerate(effects):
95
+ if isinstance(effect, Biquad):
96
+ biquads.append(effect)
97
+ else:
98
+ chain += [*_cascade(biquads, rate), _stage(index, effect, rate)]
99
+ biquads = []
100
+ return chain + _cascade(biquads, rate)
101
+
102
+
103
+ def _cascade(biquads: Sequence[Biquad], rate: int) -> list[Apply]:
104
+ """Gives one section run call for these biquads, or none for 0 biquads."""
105
+ return [Section([biquad.row(rate) for biquad in biquads]).run] if biquads else []
106
+
107
+
108
+ def _stage(index: int, effect: Any, rate: int) -> Apply:
109
+ """Starts one effect. An item whose start gives no callable raises with its index."""
110
+ start = getattr(effect, "start", None)
111
+ apply: Apply | None = None if start is None else start(rate)
112
+ if not callable(apply):
113
+ raise TypeError(
114
+ f"index {index}: expected an effect whose start gives a callable, "
115
+ f"got {type(effect).__name__}"
116
+ )
117
+ return apply
118
+
119
+
120
+ def _through(chain: Sequence[Apply], x: Samples) -> Samples:
121
+ """Runs one chunk through each stage."""
122
+ for stage in chain:
123
+ x = stage(x)
124
+ return x
125
+
126
+
127
+ def _faded(before: Samples, after: Samples) -> Samples:
128
+ """Fades linearly from one output to the other over 1 chunk."""
129
+ ramp = np.linspace(0.0, 1.0, before.size + 1, dtype=np.float32)[1:]
130
+ return before * (1.0 - ramp) + after * ramp
131
+
132
+
133
+ def _sequence(effects: Any) -> Effects:
134
+ """Refuses one update payload outside a sequence of effects."""
135
+ if isinstance(effects, str | bytes) or not isinstance(effects, Sequence):
136
+ raise TypeError(
137
+ f"{SETTING}: expected a sequence of effects, got one {type(effects).__name__}"
138
+ )
139
+ return tuple(effects)
@@ -0,0 +1,55 @@
1
+ """Output meter. LUFS loudness, dBTP true peak."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from scipy.signal import firwin
9
+
10
+ from pipecat_effects.primitives import SILENCE, Fir, Loudness, Samples, dbfs
11
+
12
+ # 48 taps at 4 times, BS.1770 Annex 2
13
+ OVERSAMPLE = 4
14
+ TAPS = 48
15
+ BAND = 1.0 / OVERSAMPLE # cutoff at the input Nyquist, as part of the oversampled Nyquist
16
+ TRUE_PEAK_TAPS = np.asarray(firwin(TAPS, BAND, window="blackman"), dtype=np.float32)
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Reading:
21
+ """One read of the output."""
22
+
23
+ lufs: float
24
+ dbtp: float
25
+
26
+
27
+ class Meter:
28
+ """Reads loudness and true peak per interval."""
29
+
30
+ def __init__(self) -> None:
31
+ self._loudness: Loudness | None = None
32
+ self._peak: Fir | None = None
33
+ self._top = 0.0
34
+
35
+ def start(self, rate: int) -> None:
36
+ """Builds both readers."""
37
+ self._loudness = Loudness(rate=rate)
38
+ self._peak = Fir(TRUE_PEAK_TAPS, factor=OVERSAMPLE)
39
+ self._top = 0.0
40
+
41
+ def write(self, x: Samples) -> None:
42
+ """Takes one chunk. It skips a chunk before start and a chunk of 0 samples."""
43
+ if self._loudness is None or self._peak is None or not x.size:
44
+ return
45
+ self._loudness.run(x)
46
+ self._top = max(self._top, float(np.abs(self._peak.run(x)).max(initial=0.0)))
47
+
48
+ def read(self) -> Reading:
49
+ """Gives both readings, then clears."""
50
+ reading = Reading(
51
+ lufs=SILENCE if self._loudness is None else self._loudness.take(),
52
+ dbtp=dbfs(self._top),
53
+ )
54
+ self._top = 0.0
55
+ return reading
@@ -0,0 +1,54 @@
1
+ """Mixer adapter. It runs one filter on the output and meters the result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pipecat.audio.filters.base_audio_filter import BaseAudioFilter
6
+ from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer
7
+ from pipecat.frames.frames import (
8
+ FilterEnableFrame,
9
+ FilterUpdateSettingsFrame,
10
+ MixerControlFrame,
11
+ MixerEnableFrame,
12
+ MixerUpdateSettingsFrame,
13
+ )
14
+
15
+ from pipecat_effects.filter import decoded
16
+ from pipecat_effects.meter import Meter, Reading
17
+ from pipecat_effects.primitives import SILENCE
18
+
19
+
20
+ class FilterMixer(BaseAudioMixer):
21
+ """Runs one filter on the output and meters each chunk."""
22
+
23
+ def __init__(self, audio_filter: BaseAudioFilter, *, channels: int) -> None:
24
+ self._filter = audio_filter
25
+ self._channels = channels
26
+ self._meter = Meter()
27
+
28
+ async def start(self, sample_rate: int) -> None:
29
+ """Starts the filter and the meter. Over 1 channel raises."""
30
+ if self._channels != 1:
31
+ raise ValueError(f"channels: expected 1, got {self._channels}")
32
+ await self._filter.start(sample_rate)
33
+ self._meter.start(sample_rate)
34
+
35
+ async def stop(self) -> None:
36
+ await self._filter.stop()
37
+
38
+ async def process_frame(self, frame: MixerControlFrame) -> None:
39
+ """Maps one mixer frame to one filter frame."""
40
+ if isinstance(frame, MixerEnableFrame):
41
+ await self._filter.process_frame(FilterEnableFrame(enable=frame.enable))
42
+ elif isinstance(frame, MixerUpdateSettingsFrame):
43
+ await self._filter.process_frame(FilterUpdateSettingsFrame(settings=frame.settings))
44
+
45
+ async def mix(self, audio: bytes) -> bytes:
46
+ """Filters one chunk and meters the int16 result."""
47
+ mixed = await self._filter.filter(audio)
48
+ self._meter.write(decoded(mixed))
49
+ return mixed
50
+
51
+ def read(self) -> Reading | None:
52
+ """Gives loudness and true peak since the last read, then clears. Silence gives None."""
53
+ reading = self._meter.read()
54
+ return None if reading.lufs <= SILENCE else reading
@@ -0,0 +1,303 @@
1
+ """Five stateful blocks. Each effect composes them and adds no state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import cmath
6
+ import math
7
+ from collections.abc import Sequence
8
+ from functools import cache
9
+ from typing import Literal, get_args
10
+
11
+ import numpy as np
12
+ from numpy.lib.stride_tricks import sliding_window_view
13
+ from numpy.typing import NDArray
14
+ from scipy.signal import sos2zpk, sosfilt, zpk2sos
15
+
16
+ type Samples = NDArray[np.floating]
17
+ type Roots = NDArray[np.complex128]
18
+ type Kind = Literal["lowpass", "highpass", "bandpass", "peak", "lowshelf", "highshelf"]
19
+
20
+ KINDS = get_args(Kind.__value__) # get_args on the alias itself gives ()
21
+ NYQUIST_RATIO = 0.45 # highest centre, as part of the rate
22
+ BLOCK_MS = 1.0 # envelope step
23
+ K_RATE = 48000 # rate of the published K-weighting sections
24
+ # fmt: off
25
+ K_SECTIONS = ( # BS.1770 stage 1 shelf and stage 2 highpass, b0 b1 b2 over a0 a1 a2
26
+ (1.53512485958697, -2.69169618940638, 1.19839281085285,
27
+ 1.0, -1.69065929318241, 0.73248077421585),
28
+ (1.0, -2.0, 1.0,
29
+ 1.0, -1.99004745483398, 0.99007225036621),
30
+ )
31
+ # fmt: on
32
+ K_MATCH_HZ = 1000.0 # the K-weighting at each rate keeps the published response here
33
+ OFFSET_LUFS = -0.691 # calibration, K-weighted mean square, BS.1770
34
+ SILENCE = -120.0 # floor of one level reading
35
+
36
+
37
+ class Section:
38
+ """One second-order section on sosfilt, or a cascade."""
39
+
40
+ def __init__(self, sos: Sequence[float] | Sequence[Sequence[float]]) -> None:
41
+ self._sos = np.asarray(sos, dtype=np.float64).reshape(-1, 6)
42
+ self._state = np.zeros((self._sos.shape[0], 2), dtype=np.float64)
43
+
44
+ @classmethod
45
+ def at(
46
+ cls, kind: Kind, *, rate: int, hz: float, q: float = 0.7071, gain_db: float = 0.0
47
+ ) -> Section:
48
+ """Gives one Audio EQ Cookbook section at this rate. A centre over the bound raises."""
49
+ return cls(row(kind, rate=rate, hz=hz, q=q, gain_db=gain_db))
50
+
51
+ def run(self, x: Samples) -> Samples:
52
+ """Filters one chunk and holds its state."""
53
+ out, self._state = sosfilt(self._sos, x.astype(np.float64), zi=self._state)
54
+ return out.astype(np.float32)
55
+
56
+
57
+ class Envelope:
58
+ """One-pole follower on 1 ms blocks. Attack on rise, release on fall, bounded rate."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ rate: int,
64
+ attack_ms: float,
65
+ release_ms: float,
66
+ max_per_second: float = math.inf,
67
+ ) -> None:
68
+ self._block = max(round(BLOCK_MS * rate / 1000.0), 1)
69
+ blocks = rate / self._block
70
+ self._attack = _pole(attack_ms, blocks)
71
+ self._release = _pole(release_ms, blocks)
72
+ self._step = max_per_second / blocks
73
+ self._value = 0.0
74
+
75
+ @property
76
+ def value(self) -> float:
77
+ """Gives one level this follower holds."""
78
+ return self._value
79
+
80
+ def run(self, x: Samples) -> Samples:
81
+ """Gives one followed level per sample. It steps once per block."""
82
+ attack, release, step, value = self._attack, self._release, self._step, self._value
83
+ levels: list[float] = []
84
+ for peak in _peaks(x, self._block):
85
+ pole = attack if peak > value else release
86
+ moved = value + (1.0 - pole) * (peak - value)
87
+ value = min(max(moved, value - step), value + step)
88
+ levels.append(value)
89
+ self._value = value
90
+ return np.repeat(np.array(levels, dtype=np.float32), self._block)[: x.size]
91
+
92
+
93
+ class Line:
94
+ """Delay line with feedback. One comb, or one allpass."""
95
+
96
+ def __init__(self, *, samples: int, feedback: float, allpass: bool = False) -> None:
97
+ self._buffer = np.zeros(max(samples, 1), dtype=np.float32)
98
+ self._feedback = np.float32(feedback)
99
+ self._allpass = allpass
100
+ self._at = 0
101
+
102
+ def run(self, x: Samples) -> Samples:
103
+ """Reads, writes with feedback, moves on."""
104
+ out = np.empty_like(x)
105
+ done = 0
106
+ while done < x.size:
107
+ step = min(self._buffer.size - self._at, x.size - done)
108
+ delayed = self._buffer[self._at : self._at + step]
109
+ stored = x[done : done + step] + self._feedback * delayed
110
+ out[done : done + step] = (
111
+ delayed - self._feedback * stored if self._allpass else delayed
112
+ )
113
+ self._buffer[self._at : self._at + step] = stored
114
+ self._at = (self._at + step) % self._buffer.size
115
+ done += step
116
+ return out
117
+
118
+
119
+ class Fir:
120
+ """Polyphase interpolator. It holds one chunk tail."""
121
+
122
+ def __init__(self, taps: Samples, *, factor: int) -> None:
123
+ phases = np.stack([taps[phase::factor] * factor for phase in range(factor)])
124
+ self._phases = np.ascontiguousarray(phases[:, ::-1].T) # one column per phase, back in time
125
+ self._tail = np.zeros(phases.shape[1] - 1, dtype=np.float32)
126
+
127
+ def run(self, x: Samples) -> Samples:
128
+ """Gives factor samples per input sample."""
129
+ block = np.concatenate((self._tail, x))
130
+ self._tail = block[block.size - self._tail.size :]
131
+ return (sliding_window_view(block, self._phases.shape[0]) @ self._phases).reshape(-1)
132
+
133
+
134
+ class Loudness:
135
+ """K-weighted loudness on 2 sections, BS.1770. One window gives the momentary value."""
136
+
137
+ def __init__(self, *, rate: int, window_ms: float = 0.0) -> None:
138
+ self._weight = Section(_k_weighting(rate))
139
+ self._window = np.zeros(round(window_ms * rate / 1000.0), dtype=np.float64)
140
+ self._at = 0
141
+ self._held = 0.0
142
+ self._square = 0.0
143
+ self._samples = 0
144
+
145
+ @property
146
+ def value(self) -> float:
147
+ """Gives loudness since one take, in LUFS."""
148
+ return lufs(self._square / self._samples if self._samples else 0.0)
149
+
150
+ def run(self, x: Samples) -> float:
151
+ """Takes one chunk. Gives window loudness, or interval loudness with no window."""
152
+ squares = np.square(self._weight.run(x).astype(np.float64))
153
+ self._square += float(squares.sum())
154
+ self._samples += x.size
155
+ if not self._window.size:
156
+ return self.value
157
+ return lufs(self._slide(squares) / self._window.size)
158
+
159
+ def _slide(self, squares: Samples) -> float:
160
+ """Writes one chunk into its ring, oldest first, and gives that window sum."""
161
+ size = self._window.size
162
+ if squares.size >= size:
163
+ self._window[:] = squares[-size:]
164
+ self._at, self._held = 0, float(self._window.sum())
165
+ return self._held
166
+ end = self._at + squares.size
167
+ if end <= size:
168
+ self._held += float(squares.sum() - self._window[self._at : end].sum())
169
+ self._window[self._at : end] = squares
170
+ else:
171
+ head = size - self._at
172
+ self._held += float(
173
+ squares.sum() - self._window[self._at :].sum() - self._window[: end - size].sum()
174
+ )
175
+ self._window[self._at :] = squares[:head]
176
+ self._window[: end - size] = squares[head:]
177
+ self._at = end % size
178
+ return self._held
179
+
180
+ def take(self) -> float:
181
+ """Gives loudness since one take, then clears it."""
182
+ taken = self.value
183
+ self._square, self._samples = 0.0, 0
184
+ return taken
185
+
186
+
187
+ def lufs(mean_square: float) -> float:
188
+ """Gives loudness of one K-weighted mean square. Silence gives its floor."""
189
+ return _floor(OFFSET_LUFS + 10.0 * math.log10(mean_square)) if mean_square > 0.0 else SILENCE
190
+
191
+
192
+ def dbfs(level: float) -> float:
193
+ """Gives one amplitude in dB. Silence gives the floor."""
194
+ return _floor(20.0 * math.log10(level)) if level > 0.0 else SILENCE
195
+
196
+
197
+ def _floor(db: float) -> float:
198
+ """Holds one reading at the floor."""
199
+ return max(db, SILENCE)
200
+
201
+
202
+ def row(
203
+ kind: Kind, *, rate: int, hz: float, q: float = 0.7071, gain_db: float = 0.0
204
+ ) -> tuple[float, ...]:
205
+ """Gives 6 coefficients of one section at this rate. A centre over its bound raises."""
206
+ bound = NYQUIST_RATIO * rate
207
+ if hz > bound:
208
+ raise ValueError(
209
+ f"hz: expected at most {bound} Hz at the rate {rate} Hz, got a centre over it"
210
+ )
211
+ w0 = 2.0 * math.pi * hz / rate
212
+ b, a = _cookbook(kind, w0=w0, alpha=math.sin(w0) / (2.0 * q), gain_db=gain_db)
213
+ return (*(value / a[0] for value in b), 1.0, a[1] / a[0], a[2] / a[0])
214
+
215
+
216
+ def _peaks(x: Samples, size: int) -> Samples:
217
+ """Gives highest value of each block. Its last block repeats one edge."""
218
+ blocks = -(-x.size // size)
219
+ padded = np.pad(x, (0, blocks * size - x.size), mode="edge")
220
+ return padded.reshape(blocks, size).max(axis=1)
221
+
222
+
223
+ def _pole(ms: float, per_second: float) -> float:
224
+ """Gives one pole of this time constant, at this step rate."""
225
+ return math.exp(-1000.0 / (ms * per_second)) if ms > 0.0 else 0.0
226
+
227
+
228
+ def _cookbook(
229
+ kind: Kind, *, w0: float, alpha: float, gain_db: float
230
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
231
+ """Gives numerator and denominator of one kind."""
232
+ cos = math.cos(w0)
233
+ amp = 10.0 ** (gain_db / 40.0)
234
+ shelf = 2.0 * math.sqrt(amp) * alpha
235
+ match kind:
236
+ case "lowpass":
237
+ return ((1.0 - cos) / 2.0, 1.0 - cos, (1.0 - cos) / 2.0), _poles(cos, alpha)
238
+ case "highpass":
239
+ return ((1.0 + cos) / 2.0, -(1.0 + cos), (1.0 + cos) / 2.0), _poles(cos, alpha)
240
+ case "bandpass":
241
+ return (alpha, 0.0, -alpha), _poles(cos, alpha)
242
+ case "peak":
243
+ return (
244
+ (1.0 + alpha * amp, -2.0 * cos, 1.0 - alpha * amp),
245
+ (1.0 + alpha / amp, -2.0 * cos, 1.0 - alpha / amp),
246
+ )
247
+ case "lowshelf":
248
+ plus, minus = amp + 1.0, amp - 1.0
249
+ return (
250
+ (
251
+ amp * (plus - minus * cos + shelf),
252
+ 2.0 * amp * (minus - plus * cos),
253
+ amp * (plus - minus * cos - shelf),
254
+ ),
255
+ (
256
+ plus + minus * cos + shelf,
257
+ -2.0 * (minus + plus * cos),
258
+ plus + minus * cos - shelf,
259
+ ),
260
+ )
261
+ case "highshelf":
262
+ plus, minus = amp + 1.0, amp - 1.0
263
+ return (
264
+ (
265
+ amp * (plus + minus * cos + shelf),
266
+ -2.0 * amp * (minus + plus * cos),
267
+ amp * (plus + minus * cos - shelf),
268
+ ),
269
+ (
270
+ plus - minus * cos + shelf,
271
+ 2.0 * (minus - plus * cos),
272
+ plus - minus * cos - shelf,
273
+ ),
274
+ )
275
+
276
+
277
+ def _poles(cos: float, alpha: float) -> tuple[float, float, float]:
278
+ """Gives one denominator each resonant kind shares."""
279
+ return (1.0 + alpha, -2.0 * cos, 1.0 - alpha)
280
+
281
+
282
+ @cache
283
+ def _k_weighting(rate: int) -> tuple[tuple[float, ...], ...]:
284
+ """Gives the K-weighting sections at this rate, from the published sections at K_RATE.
285
+
286
+ Each zero and pole moves through the s plane. The gain keeps the response at K_MATCH_HZ.
287
+ """
288
+ zeros, poles, gain = map(np.asarray, sos2zpk(K_SECTIONS))
289
+ zeros_at, poles_at = _moved(zeros, rate), _moved(poles, rate)
290
+ gain_at = gain * _response(zeros, poles, K_RATE) / _response(zeros_at, poles_at, rate)
291
+ return tuple(map(tuple, zpk2sos(zeros_at, poles_at, gain_at).tolist()))
292
+
293
+
294
+ def _moved(roots: Roots, rate: int) -> Roots:
295
+ """Moves roots from K_RATE to this rate by the bilinear transform, through the s plane."""
296
+ s = 2.0 * K_RATE * (roots - 1.0) / (roots + 1.0)
297
+ return (2.0 * rate + s) / (2.0 * rate - s)
298
+
299
+
300
+ def _response(zeros: Roots, poles: Roots, rate: int) -> float:
301
+ """Gives the unit gain magnitude of these roots at K_MATCH_HZ."""
302
+ z = cmath.exp(2j * math.pi * K_MATCH_HZ / rate)
303
+ return float(np.abs(np.prod(z - zeros) / np.prod(z - poles)))
File without changes
@@ -0,0 +1,293 @@
1
+ Metadata-Version: 2.4
2
+ Name: pipecat-effects
3
+ Version: 0.1.0
4
+ Summary: Output audio effects for pipecat. One filter, 8 effects and a loudness meter.
5
+ Keywords: pipecat,audio,effects,voice
6
+ Author: Softcery
7
+ License-Expression: BSD-2-Clause
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Topic :: Multimedia :: Sound/Audio
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: pipecat-ai>=1.10,<2
16
+ Requires-Dist: numpy>=2.0
17
+ Requires-Dist: scipy>=1.14
18
+ Requires-Python: >=3.12
19
+ Project-URL: Source, https://github.com/softcery/pipecat-effects
20
+ Project-URL: Issues, https://github.com/softcery/pipecat-effects/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # pipecat-effects
24
+
25
+ Output audio effects for [pipecat](https://github.com/pipecat-ai/pipecat). One filter, 8 effects
26
+ and one loudness meter. You build the chain. The chain adds 0 samples of latency.
27
+
28
+ ## Install
29
+
30
+ ```
31
+ pip install pipecat-effects
32
+ ```
33
+
34
+ ## Use
35
+
36
+ Pipecat 1.10.0 has no output filter field. `FilterMixer` runs the filter as the output mixer.
37
+
38
+ ```python
39
+ from pipecat.transports.base_transport import TransportParams
40
+ from pipecat_effects import (
41
+ AGC,
42
+ Biquad,
43
+ Compressor,
44
+ DeEsser,
45
+ Effects,
46
+ EffectsFilter,
47
+ FilterMixer,
48
+ Limiter,
49
+ Saturation,
50
+ )
51
+
52
+ CHAIN: Effects = (
53
+ AGC(target_lufs=-20.0),
54
+ Biquad(kind="highpass", hz=90.0),
55
+ Biquad(kind="lowshelf", hz=200.0, gain_db=2.5),
56
+ Biquad(kind="peak", hz=3200.0, q=1.2, gain_db=-2.0),
57
+ DeEsser(hz=6500.0, threshold_db=-32.0, ratio=4.0),
58
+ Compressor(threshold_db=-20.0, ratio=3.0, attack_ms=8.0, release_ms=120.0, makeup_db=2.0),
59
+ Saturation(drive=1.2, mix=0.15),
60
+ Limiter(ceiling_db=-1.0, knee_db=3.0),
61
+ )
62
+ CHANNELS = 1
63
+
64
+
65
+ def transport_params() -> TransportParams:
66
+ return TransportParams(
67
+ audio_in_enabled=True,
68
+ audio_out_enabled=True,
69
+ audio_out_channels=CHANNELS,
70
+ audio_out_mixer=FilterMixer(EffectsFilter(CHAIN), channels=CHANNELS),
71
+ )
72
+ ```
73
+
74
+ `examples/bot.py` runs one voice bot with that chain. It needs 4 pipecat extras and
75
+ `OPENAI_API_KEY`. The wheel has no `examples/`, so run it from a clone of this repository.
76
+
77
+ ```
78
+ pip install pipecat-effects "pipecat-ai[runner,webrtc,openai,silero]"
79
+ python examples/bot.py
80
+ ```
81
+
82
+ ## Listen
83
+
84
+ One sentence of Cartesia sonic-3.5 speech, 24 kHz mono, run in 40 ms chunks, the stock output
85
+ chunk of pipecat. `examples/chains.py` holds each chain. `examples/clips.py` renders the clips
86
+ with ffmpeg. GitHub mutes each player at load. Unmute it to listen. On PyPI each player is a
87
+ link. Loudness is integrated, measured by ffmpeg `ebur128`.
88
+
89
+ ### Unprocessed
90
+
91
+ https://github.com/user-attachments/assets/9b4a4b80-8168-4380-a12d-b98f9fdcaaa7
92
+
93
+ No filter. -18.3 LUFS, -1.3 dBTP.
94
+
95
+ ### EQ and compression
96
+
97
+ https://github.com/user-attachments/assets/33bbbafc-3627-416c-84f0-baf398f4922f
98
+
99
+ The chain in [Use](#use): 2.5 dB low shelf at 200 Hz, 2 dB cut at 3.2 kHz, de-esser, 3:1
100
+ compressor. -23.2 LUFS, -5.5 dBTP.
101
+
102
+ ### Telephone
103
+
104
+ https://github.com/user-attachments/assets/6fff6b7a-ef53-44a1-b275-5e29ca4ab1bb
105
+
106
+ 300 Hz to 3400 Hz band, 4:1 compressor, drive 3.0 at 0.3 mix. -22.0 LUFS, -5.1 dBTP.
107
+
108
+ ### Broadcast
109
+
110
+ https://github.com/user-attachments/assets/0e336600-fde6-4ddb-8210-bd74be7fe0b4
111
+
112
+ 100 Hz high-pass, de-esser, 4:1 compressor with 0.5 ms attack and 18 dB makeup, +4 dB at 3.5 kHz.
113
+ -16.3 LUFS, -1.4 dBTP. Against the unprocessed clip: +2.0 LU, and +3.7 dB in the share of power
114
+ from 2 to 4 kHz.
115
+
116
+ ### Room
117
+
118
+ https://github.com/user-attachments/assets/6cbc794c-5a33-4cb4-a2be-ccfebffe1f79
119
+
120
+ Schroeder reverb, 400 ms decay, 0.3 mix. -21.2 LUFS, -3.5 dBTP.
121
+
122
+ ## Build a chain
123
+
124
+ - A chain is any sequence of effects. The filter runs them in order and adds no stage.
125
+ - Build one filter per session. Each effect holds its state across chunks.
126
+ - Put `AGC` first. It reads the level before any stage changes it.
127
+ - If a chain ends without `Limiter`, the int16 cast hard clips each sample over full scale. End
128
+ each chain with `Limiter`.
129
+ - Give `FilterMixer` the `audio_out_channels` value as `channels`. `start` raises over 1 channel.
130
+ - Each effect validates its values at build time. A value outside the range raises `ValueError`
131
+ that names the field and the range.
132
+
133
+ ## Effects
134
+
135
+ | effect | field | default | range |
136
+ | --- | --- | --- | --- |
137
+ | `Gain` | `db` | 0.0 | -60 to 24 |
138
+ | `Biquad` | `kind` | required | one of lowpass, highpass, bandpass, peak, lowshelf, highshelf |
139
+ | | `hz` | required | 10 to 20000 |
140
+ | | `q` | 0.7071 | 0.1 to 20 |
141
+ | | `gain_db` | 0.0 | -24 to 24 |
142
+ | `Saturation` | `drive` | 2.0 | 0.1 to 20 |
143
+ | | `mix` | 1.0 | 0 to 1 |
144
+ | `Compressor` | `threshold_db` | -18.0 | -60 to 0 |
145
+ | | `ratio` | 3.0 | 1 to 20 |
146
+ | | `attack_ms` | 5.0 | 0 to 200 |
147
+ | | `release_ms` | 80.0 | 1 to 2000 |
148
+ | | `makeup_db` | 0.0 | -24 to 24 |
149
+ | `AGC` | `target_lufs` | -20.0 | -40 to -10 |
150
+ | | `max_db_per_second` | 6.0 | 0.1 to 20 |
151
+ | `Limiter` | `ceiling_db` | -1.0 | -24 to 0 |
152
+ | | `knee_db` | 3.0 | 0 to 12 |
153
+ | `Reverb` | `decay_ms` | 200.0 | 10 to 500 |
154
+ | | `mix` | 0.15 | 0 to 1 |
155
+ | `DeEsser` | `hz` | 6500.0 | 1000 to 20000 |
156
+ | | `q` | 1.5 | 0.1 to 20 |
157
+ | | `threshold_db` | -30.0 | -60 to 0 |
158
+ | | `ratio` | 4.0 | 1 to 20 |
159
+ | | `attack_ms` | 1.0 | 0 to 200 |
160
+ | | `release_ms` | 40.0 | 1 to 2000 |
161
+
162
+ `mix` sets the dry/wet ratio. 0 gives only the input, the dry signal. 1 gives only the processed
163
+ signal, the wet signal.
164
+
165
+ Method of each effect:
166
+
167
+ | effect | method |
168
+ | --- | --- |
169
+ | `Gain` | one multiply |
170
+ | `Biquad` | one second-order section, Audio EQ Cookbook by Robert Bristow-Johnson |
171
+ | `Saturation` | tanh waveshaper, `tanh(drive * x) / tanh(drive)`, dry/wet mix |
172
+ | `Compressor` | one envelope follower |
173
+ | `AGC` | K-weighted loudness over 400 ms, gain ramped at `max_db_per_second` or less |
174
+ | `Limiter` | memoryless soft clipper, soft knee, hard ceiling on sample peaks |
175
+ | `Reverb` | Schroeder reverberator, 4 comb filters and 2 allpass filters |
176
+ | `DeEsser` | split-band, the envelope of one band-pass band sets the cut of that band |
177
+
178
+ ## Control
179
+
180
+ Change the chain at runtime with 2 stock frames.
181
+
182
+ ```python
183
+ from pipecat.frames.frames import MixerEnableFrame, MixerUpdateSettingsFrame
184
+ from pipecat_effects import Gain, Limiter
185
+
186
+ await worker.queue_frame(MixerEnableFrame(enable=False))
187
+ await worker.queue_frame(MixerUpdateSettingsFrame(settings={"effects": (Gain(db=-3.0), Limiter())}))
188
+ ```
189
+
190
+ - `MixerEnableFrame(enable=False)` bypasses the chain. The filter gives the input, and the chain
191
+ keeps running on it, so envelopes and delay lines stay current.
192
+ - `MixerUpdateSettingsFrame` with an `effects` key builds a new chain and swaps it in.
193
+ - Each change fades over one chunk, from the output last heard to the new output. If 2 updates
194
+ arrive before one chunk, the chain last heard fades to the last chain. The chain between them
195
+ is not heard.
196
+ - `FilterMixer` maps the 2 mixer frames to `FilterEnableFrame` and `FilterUpdateSettingsFrame`.
197
+ Call `EffectsFilter.process_frame` with those 2 frames when you hold the filter directly.
198
+ - An `effects` value outside a sequence of effects raises `TypeError` that names the field. An
199
+ item whose `start` gives no callable raises `TypeError` that names the field and the index.
200
+ - A chain that fails to build raises `ValueError` that names the `effects` field. The old chain
201
+ keeps running.
202
+ - An update before `start` builds at `start`. A failed build then raises at `start` and names the
203
+ field of the effect, not `effects`.
204
+
205
+ ## Meter
206
+
207
+ ```python
208
+ reading = mixer.read() # Reading(lufs=-19.92, dbtp=-1.04), or None on silence
209
+ ```
210
+
211
+ - `FilterMixer` meters each chunk it gives to the transport, after the int16 cast.
212
+ - `FilterMixer.read()` gives a `Reading` with `lufs` and `dbtp` since the last read, then clears
213
+ both. Silence gives `None`.
214
+ - `lufs` is the K-weighted loudness of ITU-R BS.1770, the loudness standard. The published 48 kHz filter moves to the
215
+ session rate by the bilinear transform. A 997 Hz sine at 0 dBFS reads -3.01 LUFS.
216
+ - `dbtp` is the true peak, on 4 times oversampling with 48 taps.
217
+ - `Meter` reads float chunks for any other caller. Its `read` gives the floor, -120.0 dB, in
218
+ place of `None`.
219
+
220
+ ## Cost
221
+
222
+ One 40 ms chunk at 24 kHz, the stock output chunk of pipecat. The 8 effect chain above, mean of
223
+ 1000 chunks, median of 4 runs. Python 3.13.9, macOS arm64.
224
+
225
+ | path | mean | 95th |
226
+ | --- | --- | --- |
227
+ | filter | 0.245 ms | 0.265 ms |
228
+ | mixer on silence with the meter | 0.315 ms | 0.347 ms |
229
+ | true peak | 0.028 ms | 0.030 ms |
230
+ | loudness and true peak | 0.063 ms | 0.069 ms |
231
+
232
+ `python examples/bench.py --out rows.jsonl --sha <commit>` writes one row.
233
+
234
+ ## Limits
235
+
236
+ ### Chain
237
+
238
+ - The filter runs on mono. `FilterMixer.start` raises on more than 1 channel.
239
+ - The chain runs on each chunk, silence included, so attack and release stay continuous through
240
+ silence. At the stock `audio_out_10ms_chunks` of 4, one idle session costs 25 silent chunks of
241
+ 40 ms a second, 7.9 ms of compute a second on the [Cost](#cost) machine.
242
+ - A bypassed chain keeps running, so it costs the same as an active chain.
243
+ - Outside a bypass, the chunk after an update runs the old chain and the new chain.
244
+ - A chunk of 0 samples passes through. An odd byte count raises `ValueError` that names `audio`.
245
+ - A `Biquad` or `DeEsser` centre over 0.45 of the sample rate raises at `start`, with the highest
246
+ allowed centre and the rate. The error names `hz`, not the effect.
247
+ - At 8 kHz the highest centre is 3600 Hz. The chain in [Use](#use) raises there, since its
248
+ `DeEsser` sits at 6500 Hz.
249
+ - Not included: lookahead, convolution, pitch shift, formant shift. The broadcast clip holds a
250
+ peak-to-loudness ratio, true peak minus loudness, of 14.9 dB. The unprocessed clip holds
251
+ 17.0 dB. A hall reverb needs convolution.
252
+
253
+ ### Effects
254
+
255
+ - `Compressor` and `DeEsser` set the gain of each 1 ms block from the peak of that block. `AGC`
256
+ sets one target per chunk from the loudness at the chunk end. Each gain reads ahead inside its
257
+ chunk, by up to 1 ms or 1 chunk.
258
+ - `AGC` reads momentary loudness without the gate of BS.1770. Under -50 LUFS the gain holds.
259
+ - `AGC` sets the level at its place in the chain. Later stages move the output level. The EQ and
260
+ compression clip holds `AGC(target_lufs=-20.0)` and measures -23.2 LUFS.
261
+ - `Limiter` is a memoryless soft clipper. It has no attack or release, so a signal driven far
262
+ over the ceiling distorts.
263
+ - The -1 dB ceiling leaves the margin for inter-sample peaks at a codec resampler. The true peak
264
+ meter only reports.
265
+ - One section falls 12 dB per octave. A lowpass at 6000 Hz drops an 8 kHz tone by 10.0 dB at a
266
+ 24 kHz rate.
267
+ - `Reverb` takes `decay_ms` to 500.
268
+
269
+ ### Meter
270
+
271
+ - The true peak meter cuts at the input Nyquist. At a 24 kHz rate it reads a 10 kHz sine 0.5 dB
272
+ under its peak.
273
+ - The K-weighting at 8 kHz differs from the standard by 0.43 dB at most from 100 Hz to 3 kHz. At
274
+ 24 kHz it differs by 0.040 dB at most.
275
+
276
+ ### Pipecat
277
+
278
+ - With an output mixer, a flush that fails to drain does not time out. Each mixer chunk reaches
279
+ the pipeline sink and counts as progress. A flush that drains returns. The defect is in
280
+ pipecat 1.10.0 and holds for each output mixer.
281
+
282
+ ## Develop
283
+
284
+ - The package ships a `py.typed` marker. It exports `Effect`, `Apply` and `Samples` for a caller
285
+ who writes an effect.
286
+ - `make lint` checks the lock, the format, the lint rules, and the types with pyright.
287
+ - `make test` runs the tests. `make test-lowest` runs them on the lowest allowed pipecat-ai,
288
+ numpy and scipy. CI runs both.
289
+ - `make audit` checks `uv.lock` for known vulnerabilities.
290
+
291
+ ## License
292
+
293
+ BSD 2-Clause.
@@ -0,0 +1,11 @@
1
+ pipecat_effects/__init__.py,sha256=lWLjZazUv0cyS6w3im9XCpNoftiz8A7vTIL9dGFGp2Q,708
2
+ pipecat_effects/effects.py,sha256=dprtCeYMeAZORXLq29U4p3RbM-saJkkX9qLGtG7Q_u4,9030
3
+ pipecat_effects/filter.py,sha256=S-_Y_R0hhuLCmIxj10mgMHgFcGuf-P2qKub5vDFTg88,5589
4
+ pipecat_effects/meter.py,sha256=3revxTOzk7OxhwSBgc6pR2mOGOHEtXrPrwP_SYm1uPM,1639
5
+ pipecat_effects/mixer.py,sha256=ODSya3ykqHE0zE27YU3tl_GZLpTz-IEa3aeYmeAfNDw,2091
6
+ pipecat_effects/primitives.py,sha256=ufeRyuYMZyDgFbWKTEoH_cvNvj3NqMqFsqVj_lZBN9o,11730
7
+ pipecat_effects/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ pipecat_effects-0.1.0.dist-info/licenses/LICENSE,sha256=uxp0LOugmvVcPDhABOnJ5zcRpoARpD5Zc0SZ73bM8b0,1301
9
+ pipecat_effects-0.1.0.dist-info/WHEEL,sha256=-i9oRNYVXXZJUIYl5zclLIg6onEb0NLibTX34uln84w,81
10
+ pipecat_effects-0.1.0.dist-info/METADATA,sha256=BhH6AYGV-hdQQ6YNtpBJY7lqMtXF1O_zv4fOp7Edyvc,11704
11
+ pipecat_effects-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.13
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Softcery OÜ
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.