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.
- duplex_audio/__init__.py +81 -0
- duplex_audio/_version.py +24 -0
- duplex_audio/aec.py +308 -0
- duplex_audio/calibration.py +668 -0
- duplex_audio/localvqe.py +263 -0
- duplex_audio/playback.py +619 -0
- duplex_audio/pulse_io.py +296 -0
- duplex_audio/py.typed +0 -0
- duplex_audio/rt_io.py +1758 -0
- duplex_audio/streaming.py +280 -0
- duplex_audio/timing.py +298 -0
- duplex_audio-0.14.3.dist-info/METADATA +137 -0
- duplex_audio-0.14.3.dist-info/RECORD +15 -0
- duplex_audio-0.14.3.dist-info/WHEEL +4 -0
- duplex_audio-0.14.3.dist-info/licenses/LICENSE +21 -0
duplex_audio/__init__.py
ADDED
|
@@ -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"
|
duplex_audio/_version.py
ADDED
|
@@ -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)
|