duplex-audio 0.14.3__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,81 @@
1
+ """duplex-audio: acoustic echo cancellation (AEC) toolkit.
2
+
3
+ スピーカー出力がマイクへ回り込むエコーを適応フィルタで除去し、
4
+ マイクに向かって話された音声をクリーンに取り出す。
5
+ """
6
+
7
+ from duplex_audio.aec import AecProcessor, measure_erle
8
+ from duplex_audio.calibration import (
9
+ BandResponse,
10
+ CalibrationError,
11
+ CalibrationProfile,
12
+ CalibrationProfileVersionError,
13
+ analyze_loopback,
14
+ generate_chirp,
15
+ load_calibration_profile,
16
+ measure_calibration as measure_calibration,
17
+ save_calibration_profile,
18
+ )
19
+ from duplex_audio.localvqe import LocalVqeAecBackend, LocalVqeSettings
20
+ from duplex_audio.playback import (
21
+ PlaybackEvent,
22
+ PlaybackGeneration,
23
+ PlaybackQuiesce,
24
+ PlaybackState,
25
+ PlaybackStateError,
26
+ UnknownPlaybackGeneration,
27
+ )
28
+ from duplex_audio.streaming import StreamStopIncomplete
29
+ from duplex_audio.rt_io import (
30
+ AecBackend,
31
+ CalibrationResult,
32
+ CalibrationSettings,
33
+ CaptureZeroRun,
34
+ DuplexEngine,
35
+ NoNoiseSuppressor,
36
+ NoiseSuppressor,
37
+ PipelineStats,
38
+ Resampler,
39
+ RnnoiseNoiseSuppressor,
40
+ StageStats,
41
+ StreamHealth,
42
+ )
43
+
44
+ __all__ = [
45
+ "AecProcessor",
46
+ "AecBackend",
47
+ "BandResponse",
48
+ "CalibrationError",
49
+ "CalibrationProfile",
50
+ "CalibrationProfileVersionError",
51
+ "CalibrationResult",
52
+ "CalibrationSettings",
53
+ "CaptureZeroRun",
54
+ "DuplexEngine",
55
+ "LocalVqeAecBackend",
56
+ "LocalVqeSettings",
57
+ "NoNoiseSuppressor",
58
+ "NoiseSuppressor",
59
+ "PipelineStats",
60
+ "PlaybackEvent",
61
+ "PlaybackGeneration",
62
+ "PlaybackQuiesce",
63
+ "PlaybackState",
64
+ "PlaybackStateError",
65
+ "Resampler",
66
+ "RnnoiseNoiseSuppressor",
67
+ "StageStats",
68
+ "StreamHealth",
69
+ "StreamStopIncomplete",
70
+ "UnknownPlaybackGeneration",
71
+ "analyze_loopback",
72
+ "generate_chirp",
73
+ "load_calibration_profile",
74
+ "measure_erle",
75
+ "measure_calibration",
76
+ "save_calibration_profile",
77
+ ]
78
+ try:
79
+ from duplex_audio._version import __version__
80
+ except ImportError: # version file is generated at build / sync time
81
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.14.3'
22
+ __version_tuple__ = version_tuple = (0, 14, 3)
23
+
24
+ __commit_id__ = commit_id = None
duplex_audio/aec.py ADDED
@@ -0,0 +1,308 @@
1
+ """Acoustic echo cancellation core based on an NLMS adaptive filter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+
13
+ DEFAULT_BLOCK_LENGTH = 160
14
+ DEFAULT_FILTER_LENGTH = 3200
15
+ DEFAULT_STEP_SIZE = 0.45
16
+ DEFAULT_REGULARIZATION = 1e-6
17
+ DEFAULT_REFERENCE_GATE = 1e-10
18
+ DEFAULT_DOUBLE_TALK_THRESHOLD = 0.9
19
+ DEFAULT_COLD_START_STEP_SIZE = 0.25
20
+ DEFAULT_MIN_CONTROL_GAIN = 0.02
21
+ DEFAULT_MAX_CONTROL_GAIN = 1.0
22
+ DEFAULT_MIN_LEAK = 0.005
23
+ DEFAULT_CONTROL_SMOOTHING = 0.97
24
+ DEFAULT_WARMUP_ADAPTATION_MASS = 12.5
25
+
26
+
27
+ FloatArray = NDArray[np.float32]
28
+ _AdaptationMode = Literal["continuous", "geigel"]
29
+ _AdaptationPhase = Literal["warming", "tracking"]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class _ValidatedBlock:
34
+ near_end: FloatArray
35
+ reference: FloatArray
36
+
37
+
38
+ class AecProcessor:
39
+ """Stateful block processor for acoustic echo cancellation."""
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ block_length: int = DEFAULT_BLOCK_LENGTH,
45
+ filter_length: int = DEFAULT_FILTER_LENGTH,
46
+ step_size: float = DEFAULT_STEP_SIZE,
47
+ regularization: float = DEFAULT_REGULARIZATION,
48
+ reference_gate: float = DEFAULT_REFERENCE_GATE,
49
+ double_talk_threshold: float = DEFAULT_DOUBLE_TALK_THRESHOLD,
50
+ adaptation_enabled: bool = True,
51
+ adaptation_mode: _AdaptationMode = "continuous",
52
+ cold_start_step_size: float = DEFAULT_COLD_START_STEP_SIZE,
53
+ min_control_gain: float = DEFAULT_MIN_CONTROL_GAIN,
54
+ max_control_gain: float = DEFAULT_MAX_CONTROL_GAIN,
55
+ min_leak: float = DEFAULT_MIN_LEAK,
56
+ control_smoothing: float = DEFAULT_CONTROL_SMOOTHING,
57
+ warmup_adaptation_mass: float = DEFAULT_WARMUP_ADAPTATION_MASS,
58
+ ) -> None:
59
+ if block_length <= 0:
60
+ msg = "block_length must be positive"
61
+ raise ValueError(msg)
62
+ if filter_length <= 0:
63
+ msg = "filter_length must be positive"
64
+ raise ValueError(msg)
65
+ if not 0.0 < step_size <= 2.0:
66
+ msg = "step_size must be in the range (0, 2]"
67
+ raise ValueError(msg)
68
+ if regularization <= 0.0:
69
+ msg = "regularization must be positive"
70
+ raise ValueError(msg)
71
+ if reference_gate < 0.0:
72
+ msg = "reference_gate must be non-negative"
73
+ raise ValueError(msg)
74
+ if double_talk_threshold <= 0.0:
75
+ msg = "double_talk_threshold must be positive"
76
+ raise ValueError(msg)
77
+ if adaptation_mode not in ("continuous", "geigel"):
78
+ msg = "adaptation_mode must be 'continuous' or 'geigel'"
79
+ raise ValueError(msg)
80
+ if not 0.0 < cold_start_step_size <= 2.0:
81
+ msg = "cold_start_step_size must be in the range (0, 2]"
82
+ raise ValueError(msg)
83
+ if min_control_gain <= 0.0:
84
+ msg = "min_control_gain must be positive"
85
+ raise ValueError(msg)
86
+ if max_control_gain < min_control_gain:
87
+ msg = "max_control_gain must be greater than or equal to min_control_gain"
88
+ raise ValueError(msg)
89
+ if not 0.0 <= min_leak <= 1.0:
90
+ msg = "min_leak must be in the range [0, 1]"
91
+ raise ValueError(msg)
92
+ if not 0.0 <= control_smoothing < 1.0:
93
+ msg = "control_smoothing must be in the range [0, 1)"
94
+ raise ValueError(msg)
95
+ if warmup_adaptation_mass <= 0.0:
96
+ msg = "warmup_adaptation_mass must be positive"
97
+ raise ValueError(msg)
98
+
99
+ self.block_length = block_length
100
+ self.filter_length = filter_length
101
+ self.step_size = float(step_size)
102
+ self.regularization = float(regularization)
103
+ self.reference_gate = float(reference_gate)
104
+ self.double_talk_threshold = float(double_talk_threshold)
105
+ self.adaptation_enabled = adaptation_enabled
106
+ self.adaptation_mode = adaptation_mode
107
+ self.cold_start_step_size = float(cold_start_step_size)
108
+ self.min_control_gain = float(min_control_gain)
109
+ self.max_control_gain = float(max_control_gain)
110
+ self.min_leak = float(min_leak)
111
+ self.control_smoothing = float(control_smoothing)
112
+ self.warmup_adaptation_mass = float(warmup_adaptation_mass)
113
+ self._coefficients = np.zeros(filter_length, dtype=np.float64)
114
+ self._reference_history = np.zeros(filter_length, dtype=np.float64)
115
+ self._reset_controller()
116
+
117
+ @property
118
+ def coefficients(self) -> FloatArray:
119
+ """Return a copy of the current echo-path estimate."""
120
+
121
+ return self._coefficients.astype(np.float32)
122
+
123
+ def reset(self) -> None:
124
+ """Reset filter coefficients and reference history."""
125
+
126
+ self._coefficients.fill(0.0)
127
+ self._reference_history.fill(0.0)
128
+ self._reset_controller()
129
+
130
+ def process(self, near_end: FloatArray, reference: FloatArray) -> FloatArray:
131
+ """Process one fixed-size block and return the echo-cancelled signal."""
132
+
133
+ block = self._validate_block(near_end, reference)
134
+ reference_energy = float(np.dot(block.reference, block.reference))
135
+ if reference_energy < self.reference_gate:
136
+ self._ingest_reference(block.reference)
137
+ return block.near_end.copy()
138
+
139
+ clean = np.empty(self.block_length, dtype=np.float32)
140
+ effective_step_size = self._effective_step_size(block)
141
+ echo_power = 0.0
142
+ error_power = 0.0
143
+ for index, (near_sample, reference_sample) in enumerate(
144
+ zip(block.near_end, block.reference, strict=True)
145
+ ):
146
+ self._push_reference(float(reference_sample))
147
+ echo_estimate = float(np.dot(self._coefficients, self._reference_history))
148
+ error = float(near_sample) - echo_estimate
149
+ clean[index] = error
150
+ echo_power += echo_estimate * echo_estimate
151
+ error_power += error * error
152
+ if effective_step_size > 0.0:
153
+ energy = float(np.dot(self._reference_history, self._reference_history))
154
+ scale = effective_step_size * error / (energy + self.regularization)
155
+ self._coefficients += scale * self._reference_history
156
+ if self.adaptation_mode == "continuous" and self.adaptation_enabled:
157
+ self._update_controller(
158
+ estimated_echo_power=echo_power / self.block_length,
159
+ error_power=error_power / self.block_length,
160
+ effective_step_size=effective_step_size,
161
+ )
162
+ return clean
163
+
164
+ def _validate_block(
165
+ self, near_end: FloatArray, reference: FloatArray
166
+ ) -> _ValidatedBlock:
167
+ near_end_array = np.asarray(near_end)
168
+ reference_array = np.asarray(reference)
169
+ if near_end_array.ndim != 1:
170
+ msg = "near_end must be a one-dimensional array"
171
+ raise ValueError(msg)
172
+ if reference_array.ndim != 1:
173
+ msg = "reference must be a one-dimensional array"
174
+ raise ValueError(msg)
175
+ if near_end_array.shape[0] != self.block_length:
176
+ msg = f"near_end length must be {self.block_length}"
177
+ raise ValueError(msg)
178
+ if reference_array.shape[0] != self.block_length:
179
+ msg = f"reference length must be {self.block_length}"
180
+ raise ValueError(msg)
181
+ if not np.all(np.isfinite(near_end_array)):
182
+ msg = "near_end must contain only finite values"
183
+ raise ValueError(msg)
184
+ if not np.all(np.isfinite(reference_array)):
185
+ msg = "reference must contain only finite values"
186
+ raise ValueError(msg)
187
+ return _ValidatedBlock(
188
+ near_end=near_end_array.astype(np.float32, copy=False),
189
+ reference=reference_array.astype(np.float32, copy=False),
190
+ )
191
+
192
+ def _detect_double_talk(self, block: _ValidatedBlock) -> bool:
193
+ reference_peak = max(
194
+ float(np.max(np.abs(block.reference))),
195
+ float(np.max(np.abs(self._reference_history))),
196
+ )
197
+ if reference_peak == 0.0:
198
+ return False
199
+ near_end_peak = float(np.max(np.abs(block.near_end)))
200
+ return near_end_peak > self.double_talk_threshold * reference_peak
201
+
202
+ def _effective_step_size(self, block: _ValidatedBlock) -> float:
203
+ if not self.adaptation_enabled:
204
+ return 0.0
205
+ if self.adaptation_mode == "geigel":
206
+ if self._detect_double_talk(block):
207
+ return 0.0
208
+ return self.step_size
209
+ if self._adaptation_phase == "warming":
210
+ return self.cold_start_step_size
211
+ return self.step_size * self._control_gain
212
+
213
+ def _reset_controller(self) -> None:
214
+ self._adaptation_phase: _AdaptationPhase = "warming"
215
+ self._adaptation_mass = 0.0
216
+ self._control_gain = self.max_control_gain
217
+ self._echo_power_mean = 0.0
218
+ self._error_power_mean = 0.0
219
+ self._power_covariance = 0.0
220
+ self._echo_power_variance = 0.0
221
+ self._controller_initialized = False
222
+
223
+ def _update_controller(
224
+ self,
225
+ *,
226
+ estimated_echo_power: float,
227
+ error_power: float,
228
+ effective_step_size: float,
229
+ ) -> None:
230
+ self._update_power_statistics(estimated_echo_power, error_power)
231
+ if self._adaptation_phase == "warming":
232
+ self._adaptation_mass += effective_step_size
233
+ if self._adaptation_mass >= self.warmup_adaptation_mass:
234
+ self._adaptation_phase = "tracking"
235
+ return
236
+
237
+ if self._echo_power_variance <= self.regularization:
238
+ leakage = self.min_leak
239
+ else:
240
+ leakage = self._power_covariance / self._echo_power_variance
241
+ leakage = min(max(leakage, self.min_leak), 1.0)
242
+ residual_echo_power = leakage * estimated_echo_power
243
+ control_gain = residual_echo_power / (error_power + self.regularization)
244
+ self._control_gain = min(
245
+ max(control_gain, self.min_control_gain),
246
+ self.max_control_gain,
247
+ )
248
+
249
+ def _update_power_statistics(
250
+ self,
251
+ estimated_echo_power: float,
252
+ error_power: float,
253
+ ) -> None:
254
+ if not self._controller_initialized:
255
+ self._echo_power_mean = estimated_echo_power
256
+ self._error_power_mean = error_power
257
+ self._controller_initialized = True
258
+ return
259
+
260
+ echo_delta = estimated_echo_power - self._echo_power_mean
261
+ error_delta = error_power - self._error_power_mean
262
+ smoothing = self.control_smoothing
263
+ update_weight = 1.0 - smoothing
264
+ self._echo_power_mean += update_weight * echo_delta
265
+ self._error_power_mean += update_weight * error_delta
266
+ self._power_covariance = (
267
+ smoothing * self._power_covariance
268
+ + update_weight * echo_delta * error_delta
269
+ )
270
+ self._echo_power_variance = (
271
+ smoothing * self._echo_power_variance
272
+ + update_weight * echo_delta * echo_delta
273
+ )
274
+
275
+ def _ingest_reference(self, reference: FloatArray) -> None:
276
+ for sample in reference:
277
+ self._push_reference(float(sample))
278
+
279
+ def _push_reference(self, sample: float) -> None:
280
+ self._reference_history[1:] = self._reference_history[:-1]
281
+ self._reference_history[0] = sample
282
+
283
+
284
+ def measure_erle(before: FloatArray, after: FloatArray) -> float:
285
+ """Measure echo return loss enhancement in dB."""
286
+
287
+ before_array = np.asarray(before, dtype=np.float64)
288
+ after_array = np.asarray(after, dtype=np.float64)
289
+ if before_array.shape != after_array.shape:
290
+ msg = "before and after must have the same shape"
291
+ raise ValueError(msg)
292
+ if before_array.ndim != 1:
293
+ msg = "before and after must be one-dimensional arrays"
294
+ raise ValueError(msg)
295
+ if not np.all(np.isfinite(before_array)):
296
+ msg = "before must contain only finite values"
297
+ raise ValueError(msg)
298
+ if not np.all(np.isfinite(after_array)):
299
+ msg = "after must contain only finite values"
300
+ raise ValueError(msg)
301
+
302
+ before_power = float(np.mean(before_array * before_array))
303
+ after_power = float(np.mean(after_array * after_array))
304
+ if before_power == 0.0:
305
+ return 0.0
306
+ if after_power == 0.0:
307
+ return math.inf
308
+ return 10.0 * math.log10(before_power / after_power)