PyQa40x-i2c 0.1.2__tar.gz → 0.2.2__tar.gz
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.
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/PKG-INFO +1 -1
- pyqa40x_i2c-0.2.2/RELEASE_NOTES.md +23 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/_version.py +3 -3
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/analyzer.py +163 -63
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/control.py +1 -1
- pyqa40x_i2c-0.2.2/src/PyQa40x/stream.py +274 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/wave.py +1 -1
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x_i2c.egg-info/PKG-INFO +1 -1
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x_i2c.egg-info/SOURCES.txt +2 -0
- pyqa40x_i2c-0.2.2/src/PyQa40x_i2c.egg-info/scm_file_list.json +58 -0
- pyqa40x_i2c-0.2.2/src/PyQa40x_i2c.egg-info/scm_version.json +8 -0
- pyqa40x_i2c-0.1.2/RELEASE_NOTES.md +0 -11
- pyqa40x_i2c-0.1.2/src/PyQa40x/stream.py +0 -208
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/.github/workflows/code_checker.yml +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/.github/workflows/publish_package.yml +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/.github/workflows/unit_tests.yml +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/.gitignore +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/.pylint_ignore +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LICENSE +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/FirstPrinciples.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_acoustic_chirp_spl_bt.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_acoustic_dbspl.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_acoustic_energy_decay_bt.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_coherent_averaging.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_cross_correlation.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_freq_response_with_mask.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_pinknoise.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/PyQa40x_thdn.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/mask_freq.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/LibTest/mask_phase.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/MANIFEST.in +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/README.md +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/_ci/is_dev_version.sh +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/pyproject.toml +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/requirements-dev.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/requirements-test.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/requirements.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/setup.cfg +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/__init__.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/analyzer_params.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/bluetooth.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/cross_correlation.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/fft_processor.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/freq_series_plotter.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/helpers.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/math_chirp.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/math_energy.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/math_general.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/math_sines.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/math_windows.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/registers.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/series_plotter.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/sig_proc.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/wave_chirp.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x/wave_sine.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x.egg-info/PKG-INFO +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x.egg-info/SOURCES.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x.egg-info/dependency_links.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x.egg-info/requires.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x.egg-info/top_level.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x_i2c.egg-info/dependency_links.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x_i2c.egg-info/requires.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/src/PyQa40x_i2c.egg-info/top_level.txt +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/tests/__init__.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/tests/test_template.py +0 -0
- {pyqa40x_i2c-0.1.2 → pyqa40x_i2c-0.2.2}/tox.ini +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
### 0.2.2
|
|
2
|
+
|
|
3
|
+
[PATCH] Allow reliable streaming for higher sample rates (`96kHz`, `192kHz`).
|
|
4
|
+
|
|
5
|
+
### 0.2.1
|
|
6
|
+
|
|
7
|
+
[PATCH] Restore removed `Analyzer.send_receive` functionality.
|
|
8
|
+
|
|
9
|
+
### 0.2.0
|
|
10
|
+
|
|
11
|
+
[MINOR] Add endless audio capturing
|
|
12
|
+
|
|
13
|
+
### 0.1.2
|
|
14
|
+
|
|
15
|
+
[PATCH] Fix usb teardown deadlock.
|
|
16
|
+
|
|
17
|
+
### 0.1.1
|
|
18
|
+
|
|
19
|
+
[PATCH] Fix missing dependency.
|
|
20
|
+
|
|
21
|
+
### 0.1.0
|
|
22
|
+
|
|
23
|
+
[MINOR] Initial release as PyQa40x-i2c fork. Unlimited audio samples capture added.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.2'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 2)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gf75dc49b9'
|
|
@@ -1,27 +1,39 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import logging
|
|
3
|
+
import struct
|
|
4
|
+
|
|
1
5
|
import numpy as np
|
|
2
6
|
import usb1 # pip install libusb1
|
|
3
|
-
import struct
|
|
4
7
|
import atexit
|
|
5
|
-
import scipy.signal # pip install scipy
|
|
6
8
|
|
|
7
9
|
from PyQa40x.registers import Registers
|
|
8
10
|
from PyQa40x.control import Control
|
|
9
11
|
from PyQa40x.stream import Stream
|
|
10
|
-
from PyQa40x.wave_sine import Wave
|
|
11
|
-
from PyQa40x.fft_processor import FFTProcessor
|
|
12
|
-
from PyQa40x.sig_proc import SigProc
|
|
13
12
|
from PyQa40x.helpers import *
|
|
14
13
|
from PyQa40x.analyzer_params import AnalyzerParams
|
|
15
14
|
from PyQa40x.bluetooth import BluetoothAudioDevice
|
|
15
|
+
from PyQa40x.wave_sine import Wave
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Sample rate Hz vs bytes per USB transfer
|
|
20
|
+
TRANSFER_BYTES_MAP = {
|
|
21
|
+
44100: Stream.DEFAULT_TRANSFER_BYTES,
|
|
22
|
+
48000: Stream.DEFAULT_TRANSFER_BYTES,
|
|
23
|
+
96000: 32768,
|
|
24
|
+
192000: 65536,
|
|
25
|
+
}
|
|
16
26
|
|
|
17
27
|
|
|
18
28
|
class Analyzer:
|
|
29
|
+
|
|
19
30
|
def __init__(self):
|
|
20
31
|
"""
|
|
21
32
|
Initializes the Analyzer class.
|
|
22
33
|
"""
|
|
23
34
|
self.params: AnalyzerParams | None = None
|
|
24
35
|
self.context: usb1.USBContext | None = None
|
|
36
|
+
self.chunk_size: int | None = None
|
|
25
37
|
self.device: usb1.USBDeviceHandle | None = None
|
|
26
38
|
self.registers: Registers | None = None
|
|
27
39
|
self.control: Control | None = None
|
|
@@ -29,6 +41,9 @@ class Analyzer:
|
|
|
29
41
|
self.cal_data: dict | None = None
|
|
30
42
|
self.bt_device: BluetoothAudioDevice | None = None
|
|
31
43
|
self._cleanup_registered = False
|
|
44
|
+
self._capturing: bool = False
|
|
45
|
+
self._dac_writer: threading.Thread | None = None # set in start_capture()
|
|
46
|
+
self._dac_writer_error: Exception | None = None # error from DAC writer thread
|
|
32
47
|
|
|
33
48
|
def init(
|
|
34
49
|
self,
|
|
@@ -72,6 +87,7 @@ class Analyzer:
|
|
|
72
87
|
)
|
|
73
88
|
self.context = usb1.USBContext()
|
|
74
89
|
self.context.open()
|
|
90
|
+
self.chunk_size = TRANSFER_BYTES_MAP.get(sample_rate, Stream.DEFAULT_TRANSFER_BYTES)
|
|
75
91
|
|
|
76
92
|
# Attempt to open QA402 or QA403 device
|
|
77
93
|
self.device = self.context.openByVendorIDAndProductID(0x16C0, 0x4E37) # QA402
|
|
@@ -86,7 +102,7 @@ class Analyzer:
|
|
|
86
102
|
|
|
87
103
|
self.registers = Registers(self.device)
|
|
88
104
|
self.control = Control(self.registers)
|
|
89
|
-
self.stream = Stream(self.context, self.device, self.registers)
|
|
105
|
+
self.stream = Stream(self.context, self.device, self.registers, self.chunk_size)
|
|
90
106
|
|
|
91
107
|
# Load calibration data
|
|
92
108
|
self.cal_data = self.control.load_calibration()
|
|
@@ -139,6 +155,138 @@ class Analyzer:
|
|
|
139
155
|
except Exception as e:
|
|
140
156
|
print(f"An error occurred during cleanup: {e}")
|
|
141
157
|
|
|
158
|
+
@staticmethod
|
|
159
|
+
def list_audio_devices_by_sample_rate(target_sample_rate: int):
|
|
160
|
+
"""
|
|
161
|
+
List all available audio devices that support a specific sample rate
|
|
162
|
+
by calling the method from BluetoothAudioDevice.
|
|
163
|
+
"""
|
|
164
|
+
BluetoothAudioDevice.list_audio_devices_by_sample_rate(target_sample_rate)
|
|
165
|
+
|
|
166
|
+
def _convert_adc_bytes_to_volts(
|
|
167
|
+
self, raw_bytes: bytearray, n_samples: int
|
|
168
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
169
|
+
"""
|
|
170
|
+
Convert interleaved int32 ADC bytes to calibrated Volt arrays.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
raw_bytes (bytearray): Interleaved little-endian int32 stereo ADC data.
|
|
174
|
+
n_samples (int): Samples per channel to return.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
tuple[np.ndarray, np.ndarray]: (left_volts, right_volts), float64.
|
|
178
|
+
"""
|
|
179
|
+
# Reads the raw bytes and treat every 4 bytes as one signed 32‑bit integer
|
|
180
|
+
# [L0, R0, L1, R1, L2, R2, …]
|
|
181
|
+
interleaved = np.frombuffer(raw_bytes, dtype=np.int32)
|
|
182
|
+
|
|
183
|
+
# Split interleaved int32 array into left and right channels
|
|
184
|
+
left_int = interleaved[0::2][:n_samples]
|
|
185
|
+
right_int = interleaved[1::2][:n_samples]
|
|
186
|
+
|
|
187
|
+
# Convert int32 → float64 and normalise to range +-1
|
|
188
|
+
max_int_value = 2**31 - 1
|
|
189
|
+
left_f = left_int.astype(np.float64) / max_int_value
|
|
190
|
+
right_f = right_int.astype(np.float64) / max_int_value
|
|
191
|
+
|
|
192
|
+
# Factor for computing the voltage that corresponds to digital full scale
|
|
193
|
+
dbfs_correction = 10 ** ((self.params.max_input_level - 6) / 20)
|
|
194
|
+
# Stored QA40x calibration factors for the current input level.
|
|
195
|
+
cal_adc_left, cal_adc_right = self.control.get_adc_cal(
|
|
196
|
+
self.cal_data, self.params.max_input_level
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Apply ADC calibration and dBFS → Volts conversion
|
|
200
|
+
left_volts = left_f * cal_adc_left * dbfs_correction
|
|
201
|
+
right_volts = right_f * cal_adc_right * dbfs_correction
|
|
202
|
+
|
|
203
|
+
return left_volts, right_volts
|
|
204
|
+
|
|
205
|
+
def start_capture(self) -> None:
|
|
206
|
+
"""
|
|
207
|
+
Starts a continuous capture of ADC data in a background thread.
|
|
208
|
+
The captured data can be retrieved later using the `read_capture` method.
|
|
209
|
+
"""
|
|
210
|
+
if self._capturing:
|
|
211
|
+
raise RuntimeError("Capture is already in progress.")
|
|
212
|
+
|
|
213
|
+
self._capturing = True
|
|
214
|
+
self._dac_writer_error = None
|
|
215
|
+
self.stream.start()
|
|
216
|
+
self._dac_writer = threading.Thread(
|
|
217
|
+
target=self._dac_writer_loop, name="qa40x-dac-writer-thread", daemon=True
|
|
218
|
+
)
|
|
219
|
+
self._dac_writer.start()
|
|
220
|
+
|
|
221
|
+
def _dac_writer_loop(self) -> None:
|
|
222
|
+
"""Continuously submit zero-DAC / ADC-read transfer pairs while capturing."""
|
|
223
|
+
while self._capturing and self.stream.running:
|
|
224
|
+
try:
|
|
225
|
+
self.stream.write_zeros(self.chunk_size)
|
|
226
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
227
|
+
# Racing with stop() is expected; a real failure while we still
|
|
228
|
+
# expect to run is recorded in the post-loop reconciliation below.
|
|
229
|
+
if self._capturing and self.stream.running:
|
|
230
|
+
self._dac_writer_error = exc
|
|
231
|
+
logger.error("DAC writer failed; stopping capture.", exc_info=exc)
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
# If the loop ended but we never asked to stop, the stream halted
|
|
235
|
+
# underneath us (error or ADC overflow): record it and clear _capturing so
|
|
236
|
+
# the analyzer isn't wedged "capturing" behind a dead writer thread.
|
|
237
|
+
if self._capturing:
|
|
238
|
+
self._capturing = False
|
|
239
|
+
if self._dac_writer_error is None:
|
|
240
|
+
self._dac_writer_error = self.stream.overflow_error or RuntimeError(
|
|
241
|
+
"Stream stopped unexpectedly during capture."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def read_capture(self, n_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
|
245
|
+
"""
|
|
246
|
+
Return the next `n_samples` samples per channel as Volt arrays.
|
|
247
|
+
|
|
248
|
+
Blocks until the samples are available. Consecutive calls return
|
|
249
|
+
contiguous audio (no gap) because the stream is never stopped between
|
|
250
|
+
reads. Returns fewer than `n_samples` only if the capture is stopping.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
n_samples (int): Samples per channel to return.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
tuple[np.ndarray, np.ndarray]: (left_volts, right_volts), float64.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
RuntimeError: If the DAC writer thread failed during capture.
|
|
260
|
+
AdcOverflowError: If capture data was not read fast enough.
|
|
261
|
+
"""
|
|
262
|
+
if self._dac_writer_error is not None:
|
|
263
|
+
raise RuntimeError(
|
|
264
|
+
"DAC writer thread encountered an error during capture."
|
|
265
|
+
) from self._dac_writer_error
|
|
266
|
+
|
|
267
|
+
need_bytes = n_samples * 8 # 2 channels x int32
|
|
268
|
+
|
|
269
|
+
raw = self.stream.consume_adc(
|
|
270
|
+
need_bytes, should_keep_waiting=lambda: self._capturing
|
|
271
|
+
)
|
|
272
|
+
got_samples = len(raw) // 8
|
|
273
|
+
return self._convert_adc_bytes_to_volts(raw, got_samples)
|
|
274
|
+
|
|
275
|
+
def stop_capture(self) -> None:
|
|
276
|
+
"""
|
|
277
|
+
Stop the continuous capture: end the feeder thread and stop the stream.
|
|
278
|
+
|
|
279
|
+
Order matters: clearing `_capturing` and stopping the stream releases a
|
|
280
|
+
dac writer blocked on the dacQueue (stop() cancels transfers and drains the
|
|
281
|
+
queue), after which the dac writer observes `_capturing == False` and exits.
|
|
282
|
+
"""
|
|
283
|
+
if not self._capturing:
|
|
284
|
+
return
|
|
285
|
+
self._capturing = False
|
|
286
|
+
self.stream.stop()
|
|
287
|
+
if self._dac_writer is not None:
|
|
288
|
+
self._dac_writer.join(timeout=5.0)
|
|
289
|
+
|
|
142
290
|
def send_receive(self, left_wave: Wave, right_wave: Wave) -> tuple[Wave, Wave]:
|
|
143
291
|
"""
|
|
144
292
|
Sends DAC data to the device and receives ADC data.
|
|
@@ -182,12 +330,10 @@ class Analyzer:
|
|
|
182
330
|
interleaved_dac_data[1::2] = right_dac_data_float
|
|
183
331
|
|
|
184
332
|
# Convert to bytes, multiplying by max int value
|
|
185
|
-
max_int_value = 2**31 - 1
|
|
333
|
+
max_int_value = 2 ** 31 - 1
|
|
186
334
|
interleaved_dac_data = (interleaved_dac_data * max_int_value).astype(np.int32)
|
|
187
335
|
|
|
188
|
-
|
|
189
|
-
chunk_size = 16384 # 16k bytes
|
|
190
|
-
num_ints_per_chunk = chunk_size // 4 # 32-bit ints, so 4 bytes per int
|
|
336
|
+
num_ints_per_chunk = self.chunk_size // 4 # 32-bit ints, so 4 bytes per int
|
|
191
337
|
total_chunks = len(interleaved_dac_data) // num_ints_per_chunk
|
|
192
338
|
|
|
193
339
|
self.stream.start()
|
|
@@ -201,8 +347,8 @@ class Analyzer:
|
|
|
201
347
|
try:
|
|
202
348
|
for i in range(total_chunks):
|
|
203
349
|
chunk = interleaved_dac_data[
|
|
204
|
-
|
|
205
|
-
|
|
350
|
+
i * num_ints_per_chunk: (i + 1) * num_ints_per_chunk
|
|
351
|
+
]
|
|
206
352
|
buffer = struct.pack("<%di" % len(chunk), *chunk)
|
|
207
353
|
self.stream.write(buffer)
|
|
208
354
|
finally:
|
|
@@ -255,11 +401,6 @@ class Analyzer:
|
|
|
255
401
|
|
|
256
402
|
return left_wave, right_wave
|
|
257
403
|
|
|
258
|
-
@staticmethod
|
|
259
|
-
def list_audio_devices_by_sample_rate(target_sample_rate: int):
|
|
260
|
-
"""List all available audio devices that support a specific sample rate by calling the method from BluetoothAudioDevice."""
|
|
261
|
-
BluetoothAudioDevice.list_audio_devices_by_sample_rate(target_sample_rate)
|
|
262
|
-
|
|
263
404
|
def capture(self, total_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
|
264
405
|
"""
|
|
265
406
|
Captures ``total_samples`` samples from both ADC input channels without
|
|
@@ -285,51 +426,10 @@ class Analyzer:
|
|
|
285
426
|
two float64 arrays each of length ``total_samples``, values
|
|
286
427
|
in Volts (calibrated, same scale as ``send_receive()``).
|
|
287
428
|
"""
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
# Number of DAC write / ADC read pairs needed to cover need_bytes.
|
|
293
|
-
# Each pair transfers chunk_bytes ADC bytes.
|
|
294
|
-
n_chunks = -(-need_bytes // chunk_bytes) # ceiling division
|
|
295
|
-
|
|
296
|
-
max_int_value = 2**31 - 1
|
|
297
|
-
|
|
298
|
-
# -- single stream start ------------------------------------------------
|
|
299
|
-
self.stream.start()
|
|
429
|
+
# A fixed-length capture is just a continuous capture read once: start
|
|
430
|
+
# the gapless stream, read exactly total_samples, then stop.
|
|
431
|
+
self.start_capture()
|
|
300
432
|
try:
|
|
301
|
-
|
|
302
|
-
# controller can pipeline them; no stop/restart in the middle.
|
|
303
|
-
for _ in range(n_chunks):
|
|
304
|
-
self.stream.write_zeros(chunk_bytes)
|
|
305
|
-
|
|
306
|
-
# Block until we have exactly the bytes we need
|
|
307
|
-
raw_bytes = self.stream.collect_adc_exact(need_bytes)
|
|
433
|
+
return self.read_capture(total_samples)
|
|
308
434
|
finally:
|
|
309
|
-
self.
|
|
310
|
-
# -- stream stopped -----------------------------------------------------
|
|
311
|
-
|
|
312
|
-
# Deserialise interleaved int32 ADC data
|
|
313
|
-
interleaved = np.frombuffer(raw_bytes, dtype=np.int32)
|
|
314
|
-
|
|
315
|
-
left_int = interleaved[0::2]
|
|
316
|
-
right_int = interleaved[1::2]
|
|
317
|
-
|
|
318
|
-
# Trim to exact length (collect_adc_exact already trims, but be safe)
|
|
319
|
-
left_int = left_int[:total_samples]
|
|
320
|
-
right_int = right_int[:total_samples]
|
|
321
|
-
|
|
322
|
-
# Convert int32 → float64 in the range ±1
|
|
323
|
-
left_f = left_int.astype(np.float64) / max_int_value
|
|
324
|
-
right_f = right_int.astype(np.float64) / max_int_value
|
|
325
|
-
|
|
326
|
-
# Apply ADC calibration and dBFS → Volts conversion
|
|
327
|
-
cal_adc_left, cal_adc_right = self.control.get_adc_cal(
|
|
328
|
-
self.cal_data, self.params.max_input_level
|
|
329
|
-
)
|
|
330
|
-
adc_dbfs_correction = 10 ** ((self.params.max_input_level - 6) / 20)
|
|
331
|
-
|
|
332
|
-
left_volts = left_f * cal_adc_left * adc_dbfs_correction
|
|
333
|
-
right_volts = right_f * cal_adc_right * adc_dbfs_correction
|
|
334
|
-
|
|
335
|
-
return left_volts, right_volts
|
|
435
|
+
self.stop_capture()
|
|
@@ -46,7 +46,7 @@ class Control:
|
|
|
46
46
|
"""
|
|
47
47
|
val = self.samplerate2reg[rate]
|
|
48
48
|
self.registers.write(9, val)
|
|
49
|
-
time.sleep(0.
|
|
49
|
+
time.sleep(0.2) # A small delay to ensure the sample rate is set
|
|
50
50
|
|
|
51
51
|
def load_calibration(self):
|
|
52
52
|
"""
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
import queue
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import usb1
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from PyQa40x.registers import Registers
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AdcOverflowError(RuntimeError):
|
|
15
|
+
"""
|
|
16
|
+
Raised when the ADC receive buffer exceeds its high-water mark.
|
|
17
|
+
|
|
18
|
+
The capture pipeline requires the consumer to read captured data
|
|
19
|
+
continuously (see ``consume_adc``). If the consumer falls behind, the
|
|
20
|
+
receive buffer grows without bound toward OOM. Rather than leak memory,
|
|
21
|
+
the stream treats this as a hard error: the worker thread latches it and
|
|
22
|
+
``consume_adc`` re-raises it on the consumer's thread.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Stream:
|
|
27
|
+
"""
|
|
28
|
+
Class to manage buffer streaming, with event worker thread.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
TRANSFER_TIMEOUT_MS = 1000 # Timeout for USB transfers in milliseconds
|
|
32
|
+
DEFAULT_TRANSFER_BYTES = 16384 # 16 kB per USB bulk transfer (hardware chunk size)
|
|
33
|
+
# cap on unread ADC data before overflow
|
|
34
|
+
MAX_BUFFERED_BYTES = 256 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
context: usb1.USBContext,
|
|
39
|
+
device: usb1.USBDeviceHandle,
|
|
40
|
+
registers: Registers,
|
|
41
|
+
transfer_bytes_size: int = DEFAULT_TRANSFER_BYTES
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize the Stream.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
context (usb1.USBContext): The USB context.
|
|
48
|
+
device (usb1.USBDeviceHandle): The USB device handle.
|
|
49
|
+
registers (Registers): The Registers instance for control writes.
|
|
50
|
+
transfer_bytes_size (int):
|
|
51
|
+
"""
|
|
52
|
+
self.context = context
|
|
53
|
+
self.device = device
|
|
54
|
+
self.registers = registers
|
|
55
|
+
self.transfer_bytes_size = transfer_bytes_size # USB bulk transfer (hardware chunk size)
|
|
56
|
+
self.endpoint_read = usb1.ENDPOINT_IN | 0x02 # EP2 in
|
|
57
|
+
self.endpoint_write = usb1.ENDPOINT_OUT | 0x02 # EP2 out
|
|
58
|
+
self.dacQueue = queue.Queue(
|
|
59
|
+
maxsize=5
|
|
60
|
+
) # Max. 5 overlapping buffers in flight, block on more
|
|
61
|
+
self.adcQueue = queue.Queue() # Unlimited queue for received data buffers
|
|
62
|
+
self.transfer_helper = usb1.USBTransferHelper() # Use the callback dispatcher
|
|
63
|
+
# Handle COMPLETED transfers normally; also handle ERROR/TIMED_OUT/CANCELLED
|
|
64
|
+
# so the queues are always drained and dacQueue.put() never deadlocks.
|
|
65
|
+
for status in (
|
|
66
|
+
usb1.TRANSFER_COMPLETED,
|
|
67
|
+
usb1.TRANSFER_ERROR,
|
|
68
|
+
usb1.TRANSFER_TIMED_OUT,
|
|
69
|
+
usb1.TRANSFER_CANCELLED,
|
|
70
|
+
usb1.TRANSFER_STALL,
|
|
71
|
+
usb1.TRANSFER_NO_DEVICE,
|
|
72
|
+
usb1.TRANSFER_OVERFLOW,
|
|
73
|
+
):
|
|
74
|
+
self.transfer_helper.setEventCallback(status, self.callback)
|
|
75
|
+
self.thread: threading.Thread | None = None
|
|
76
|
+
self.running = False
|
|
77
|
+
# In-flight transfers, keyed by identity; pruned in callback, bulk-cancelled in stop.
|
|
78
|
+
self._pending_transfers: set = set()
|
|
79
|
+
# raised to break the worker out of stuck queues
|
|
80
|
+
self._force_stop = threading.Event()
|
|
81
|
+
self._transfer_lock = threading.Lock() # protects _pending_transfers
|
|
82
|
+
self._epoch = 0 # session counter; stale-session callbacks are ignored
|
|
83
|
+
self._adc_data = queue.Queue() # received ADC data, one chunk per item
|
|
84
|
+
self._adc_data_carry = bytearray() # leftover ADC bytes between consume_adc calls
|
|
85
|
+
# latched, raised by consume_adc
|
|
86
|
+
self._overflow_error: AdcOverflowError | None = None
|
|
87
|
+
self._max_queued_chunks = self.MAX_BUFFERED_BYTES // self.transfer_bytes_size
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def overflow_error(self) -> "AdcOverflowError | None":
|
|
91
|
+
"""Latched ADC overflow error, or None if the buffer never overran."""
|
|
92
|
+
return self._overflow_error
|
|
93
|
+
|
|
94
|
+
def start(self) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Start streaming and spawn the worker thread.
|
|
97
|
+
"""
|
|
98
|
+
self._force_stop.clear()
|
|
99
|
+
self._epoch += 1 # new session; callbacks from prior sessions are ignored
|
|
100
|
+
self.thread = threading.Thread(target=self.worker, daemon=True)
|
|
101
|
+
self.running = True
|
|
102
|
+
self.thread.start()
|
|
103
|
+
self._adc_data = queue.Queue() # Reset the ADC data queue
|
|
104
|
+
self._adc_data_carry = bytearray() # Reset the carry buffer
|
|
105
|
+
self._overflow_error = None # Reset the overflow error
|
|
106
|
+
self.registers.write(8, 0x05) # Start streaming
|
|
107
|
+
|
|
108
|
+
def stop(self) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Stop streaming and end the worker thread.
|
|
111
|
+
|
|
112
|
+
Sets ``running`` to False and cancels in-flight USB transfers so their
|
|
113
|
+
callbacks fire and drain the queues, which allows the worker loop to
|
|
114
|
+
exit naturally. If the worker has not exited within 5 seconds (e.g.
|
|
115
|
+
because a USB cancellation is slow), ``_force_stop`` is set so the
|
|
116
|
+
worker breaks unconditionally after its current
|
|
117
|
+
``handleEventsTimeout(0.1)`` call (≤ 100 ms), and we give it one
|
|
118
|
+
further second to join before proceeding.
|
|
119
|
+
"""
|
|
120
|
+
# Cancel any in-flight transfers so their callbacks fire and drain the queues,
|
|
121
|
+
# allowing the worker thread to exit its loop. The lock ensure no transfer is added
|
|
122
|
+
# to the list while we are cancelling.
|
|
123
|
+
with self._transfer_lock:
|
|
124
|
+
self.running = False
|
|
125
|
+
for transfer in self._pending_transfers:
|
|
126
|
+
try:
|
|
127
|
+
transfer.cancel()
|
|
128
|
+
except Exception: # pylint: disable=broad-except
|
|
129
|
+
pass
|
|
130
|
+
self._pending_transfers.clear()
|
|
131
|
+
|
|
132
|
+
if self.thread is not None and self.thread.is_alive():
|
|
133
|
+
self.thread.join(timeout=5.0)
|
|
134
|
+
if self.thread.is_alive():
|
|
135
|
+
# Worker is stuck (slow USB cancellation). Force it out so
|
|
136
|
+
# context.close() does not deadlock.
|
|
137
|
+
self._force_stop.set()
|
|
138
|
+
self.thread.join(timeout=1.0)
|
|
139
|
+
self.registers.write(8, 0x00) # Stop streaming
|
|
140
|
+
|
|
141
|
+
def worker(self) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Event loop for the asynchronous transfers.
|
|
144
|
+
|
|
145
|
+
Runs until *both* conditions are true:
|
|
146
|
+
* ``running`` is False (``stop()`` has been called), and
|
|
147
|
+
* both USB queues are empty (all callbacks have fired).
|
|
148
|
+
|
|
149
|
+
An early exit is also triggered by ``_force_stop`` (set by ``stop()``
|
|
150
|
+
when the normal join times out) so that ``context.close()`` is never
|
|
151
|
+
called while this thread is still inside ``handleEventsTimeout``.
|
|
152
|
+
"""
|
|
153
|
+
while self.running or not (
|
|
154
|
+
self.dacQueue.empty() and self.adcQueue.empty()
|
|
155
|
+
): # Play until the last
|
|
156
|
+
if self._force_stop.is_set():
|
|
157
|
+
break
|
|
158
|
+
# 100 ms timeout prevents blocking forever
|
|
159
|
+
self.context.handleEventsTimeout(0.1)
|
|
160
|
+
|
|
161
|
+
def callback(self, transfer: usb1.USBTransfer) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Handle a completed transfer (runs on the worker thread).
|
|
164
|
+
|
|
165
|
+
On a completed ADC read, buffer the bytes; if the receive queue passes
|
|
166
|
+
its limit, latch an AdcOverflowError and stop so consume_adc can raise it.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
transfer (usb1.USBTransfer): The USB transfer that has completed.
|
|
170
|
+
"""
|
|
171
|
+
current = transfer.getUserData() == self._epoch # ignore stale-session data
|
|
172
|
+
if transfer.getEndpoint() == self.endpoint_read:
|
|
173
|
+
if current and transfer.getStatus() == usb1.TRANSFER_COMPLETED:
|
|
174
|
+
if self._overflow_error is None:
|
|
175
|
+
# Copy: the transfer buffer is freed once we prune the transfer.
|
|
176
|
+
self._adc_data.put(bytes(transfer.getBuffer()))
|
|
177
|
+
if self._adc_data.qsize() > self._max_queued_chunks:
|
|
178
|
+
self._overflow_error = AdcOverflowError(
|
|
179
|
+
"ADC receive queue exceeded high-water mark "
|
|
180
|
+
f"({self._adc_data.qsize()} chunks); consumer not "
|
|
181
|
+
"reading capture fast enough."
|
|
182
|
+
)
|
|
183
|
+
self.running = False # unwind feeder + worker
|
|
184
|
+
logger.error(str(self._overflow_error))
|
|
185
|
+
self.adcQueue.get_nowait() # Always drain the queue
|
|
186
|
+
else:
|
|
187
|
+
self.dacQueue.get_nowait() # Always drain the queue
|
|
188
|
+
|
|
189
|
+
# Prune this finished transfer so _pending_transfers stays bounded during
|
|
190
|
+
# a long capture; discard() is a no-op if stop() already cleared it.
|
|
191
|
+
with self._transfer_lock:
|
|
192
|
+
self._pending_transfers.discard(transfer)
|
|
193
|
+
|
|
194
|
+
def write(self, buffer: bytes) -> None:
|
|
195
|
+
"""Submit one DAC playback buffer and one matching ADC read transfer."""
|
|
196
|
+
with self._transfer_lock:
|
|
197
|
+
if not self.running:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
"Stream is not running; call start() first.")
|
|
200
|
+
dac = self.device.getTransfer()
|
|
201
|
+
dac.setBulk(self.endpoint_write, buffer, self.transfer_helper,
|
|
202
|
+
self._epoch, self.TRANSFER_TIMEOUT_MS)
|
|
203
|
+
dac.submit()
|
|
204
|
+
self._pending_transfers.add(dac)
|
|
205
|
+
read = self.device.getTransfer()
|
|
206
|
+
read.setBulk(self.endpoint_read, len(buffer), self.transfer_helper,
|
|
207
|
+
self._epoch, self.TRANSFER_TIMEOUT_MS)
|
|
208
|
+
read.submit()
|
|
209
|
+
self._pending_transfers.add(read)
|
|
210
|
+
self.dacQueue.put(dac)
|
|
211
|
+
self.adcQueue.put(read)
|
|
212
|
+
|
|
213
|
+
def write_zeros(self, chunk_bytes: int = DEFAULT_TRANSFER_BYTES) -> None:
|
|
214
|
+
"""Feed the ADC pipeline with a zero DAC buffer (continuous capture)."""
|
|
215
|
+
self.write(bytes(chunk_bytes))
|
|
216
|
+
|
|
217
|
+
def collect_remaining_adc_data(self):
|
|
218
|
+
"""
|
|
219
|
+
Waits for all remaining ADC transfers to complete and returns the collected data.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
bytearray: The collected ADC data.
|
|
223
|
+
"""
|
|
224
|
+
while not self.adcQueue.empty():
|
|
225
|
+
time.sleep(0.01)
|
|
226
|
+
|
|
227
|
+
while not self._adc_data.empty():
|
|
228
|
+
chunk = self._adc_data.get()
|
|
229
|
+
self._adc_data_carry.extend(chunk)
|
|
230
|
+
|
|
231
|
+
return self._adc_data_carry
|
|
232
|
+
|
|
233
|
+
def consume_adc(
|
|
234
|
+
self,
|
|
235
|
+
need_bytes: int,
|
|
236
|
+
should_keep_waiting: Callable[[], bool] | None = None,
|
|
237
|
+
poll_s: float = 0.005,
|
|
238
|
+
) -> bytearray:
|
|
239
|
+
"""
|
|
240
|
+
Block until `need_bytes` of ADC data are available, then remove and
|
|
241
|
+
return exactly that many bytes from the front of the receive buffer.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
need_bytes (int): Number of bytes to consume.
|
|
245
|
+
should_keep_waiting (Callable[[], bool] | None): Polled each iteration;
|
|
246
|
+
when it returns False, stop waiting and return what is available.
|
|
247
|
+
poll_s (float): Poll interval in seconds.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
bytearray: Up to `need_bytes` of ADC data (fewer only on early stop).
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
AdcOverflowError: If the receive buffer exceeded its limit because
|
|
254
|
+
captured data was not read fast enough.
|
|
255
|
+
"""
|
|
256
|
+
while len(self._adc_data_carry) < need_bytes:
|
|
257
|
+
if self._overflow_error is not None:
|
|
258
|
+
raise self._overflow_error
|
|
259
|
+
if should_keep_waiting is not None and not should_keep_waiting():
|
|
260
|
+
break
|
|
261
|
+
if not self.running and self._adc_data.empty():
|
|
262
|
+
break # stream stopped and no more data will arrive, return what we have
|
|
263
|
+
try:
|
|
264
|
+
chunk = self._adc_data.get(timeout=poll_s)
|
|
265
|
+
self._adc_data_carry.extend(chunk)
|
|
266
|
+
except queue.Empty:
|
|
267
|
+
continue # No new data yet, loop and check conditions again
|
|
268
|
+
|
|
269
|
+
if self._overflow_error is not None:
|
|
270
|
+
raise self._overflow_error
|
|
271
|
+
take_bytes_count = min(need_bytes, len(self._adc_data_carry))
|
|
272
|
+
out_bytes = self._adc_data_carry[:take_bytes_count]
|
|
273
|
+
del self._adc_data_carry[:take_bytes_count]
|
|
274
|
+
return out_bytes
|
|
@@ -26,7 +26,7 @@ class Wave:
|
|
|
26
26
|
|
|
27
27
|
self.amplitude_unit: str = amplitude_unit # Lowercase. Use dbv or dbu
|
|
28
28
|
self.distortion_unit: str = distortion_unit # Lowercase. Use db or pct
|
|
29
|
-
self.energy_unit: str = energy_unit # Lowercase. Use dbv, dbu, V
|
|
29
|
+
self.energy_unit: str = energy_unit # Lowercase. Use dbv, dbu, V or V^2
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def set_buffer(self, buffer: np.ndarray):
|
|
@@ -55,6 +55,8 @@ src/PyQa40x_i2c.egg-info/PKG-INFO
|
|
|
55
55
|
src/PyQa40x_i2c.egg-info/SOURCES.txt
|
|
56
56
|
src/PyQa40x_i2c.egg-info/dependency_links.txt
|
|
57
57
|
src/PyQa40x_i2c.egg-info/requires.txt
|
|
58
|
+
src/PyQa40x_i2c.egg-info/scm_file_list.json
|
|
59
|
+
src/PyQa40x_i2c.egg-info/scm_version.json
|
|
58
60
|
src/PyQa40x_i2c.egg-info/top_level.txt
|
|
59
61
|
tests/__init__.py
|
|
60
62
|
tests/test_template.py
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
"tox.ini",
|
|
4
|
+
"requirements.txt",
|
|
5
|
+
"requirements-test.txt",
|
|
6
|
+
"requirements-dev.txt",
|
|
7
|
+
"MANIFEST.in",
|
|
8
|
+
"pyproject.toml",
|
|
9
|
+
".pylint_ignore",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"RELEASE_NOTES.md",
|
|
12
|
+
"README.md",
|
|
13
|
+
".gitignore",
|
|
14
|
+
".github/workflows/code_checker.yml",
|
|
15
|
+
".github/workflows/unit_tests.yml",
|
|
16
|
+
".github/workflows/publish_package.yml",
|
|
17
|
+
"tests/__init__.py",
|
|
18
|
+
"tests/test_template.py",
|
|
19
|
+
"src/PyQa40x/sig_proc.py",
|
|
20
|
+
"src/PyQa40x/wave.py",
|
|
21
|
+
"src/PyQa40x/helpers.py",
|
|
22
|
+
"src/PyQa40x/freq_series_plotter.py",
|
|
23
|
+
"src/PyQa40x/math_sines.py",
|
|
24
|
+
"src/PyQa40x/wave_sine.py",
|
|
25
|
+
"src/PyQa40x/registers.py",
|
|
26
|
+
"src/PyQa40x/math_windows.py",
|
|
27
|
+
"src/PyQa40x/math_energy.py",
|
|
28
|
+
"src/PyQa40x/math_chirp.py",
|
|
29
|
+
"src/PyQa40x/analyzer.py",
|
|
30
|
+
"src/PyQa40x/analyzer_params.py",
|
|
31
|
+
"src/PyQa40x/__init__.py",
|
|
32
|
+
"src/PyQa40x/math_general.py",
|
|
33
|
+
"src/PyQa40x/control.py",
|
|
34
|
+
"src/PyQa40x/wave_chirp.py",
|
|
35
|
+
"src/PyQa40x/series_plotter.py",
|
|
36
|
+
"src/PyQa40x/fft_processor.py",
|
|
37
|
+
"src/PyQa40x/stream.py",
|
|
38
|
+
"src/PyQa40x/cross_correlation.py",
|
|
39
|
+
"src/PyQa40x/bluetooth.py",
|
|
40
|
+
"src/PyQa40x.egg-info/SOURCES.txt",
|
|
41
|
+
"src/PyQa40x.egg-info/dependency_links.txt",
|
|
42
|
+
"src/PyQa40x.egg-info/requires.txt",
|
|
43
|
+
"src/PyQa40x.egg-info/PKG-INFO",
|
|
44
|
+
"src/PyQa40x.egg-info/top_level.txt",
|
|
45
|
+
"_ci/is_dev_version.sh",
|
|
46
|
+
"LibTest/PyQa40x_thdn.py",
|
|
47
|
+
"LibTest/PyQa40x_freq_response_with_mask.py",
|
|
48
|
+
"LibTest/mask_phase.txt",
|
|
49
|
+
"LibTest/PyQa40x_coherent_averaging.py",
|
|
50
|
+
"LibTest/FirstPrinciples.py",
|
|
51
|
+
"LibTest/PyQa40x_cross_correlation.py",
|
|
52
|
+
"LibTest/PyQa40x_pinknoise.py",
|
|
53
|
+
"LibTest/mask_freq.txt",
|
|
54
|
+
"LibTest/PyQa40x_acoustic_energy_decay_bt.py",
|
|
55
|
+
"LibTest/PyQa40x_acoustic_dbspl.py",
|
|
56
|
+
"LibTest/PyQa40x_acoustic_chirp_spl_bt.py"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
import threading
|
|
2
|
-
import queue
|
|
3
|
-
import time
|
|
4
|
-
import usb1
|
|
5
|
-
import numpy as np
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Stream:
|
|
9
|
-
"""
|
|
10
|
-
Class to manage buffer streaming, with event worker thread.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
def __init__(self, context, device, registers):
|
|
14
|
-
"""
|
|
15
|
-
Initializes the Stream class.
|
|
16
|
-
|
|
17
|
-
Args:
|
|
18
|
-
context (usb1.USBContext): The USB context.
|
|
19
|
-
device (usb1.USBDeviceHandle): The USB device handle.
|
|
20
|
-
registers (Registers): An instance of the Registers class.
|
|
21
|
-
"""
|
|
22
|
-
self.context = context
|
|
23
|
-
self.device = device
|
|
24
|
-
self.registers = registers
|
|
25
|
-
self.endpoint_read = usb1.ENDPOINT_IN | 0x02 # EP2 in
|
|
26
|
-
self.endpoint_write = usb1.ENDPOINT_OUT | 0x02 # EP2 out
|
|
27
|
-
self.dacQueue = queue.Queue(
|
|
28
|
-
maxsize=5
|
|
29
|
-
) # Max. 5 overlapping buffers in flight, block on more
|
|
30
|
-
self.adcQueue = queue.Queue() # Unlimited queue for received data buffers
|
|
31
|
-
self.transfer_helper = usb1.USBTransferHelper() # Use the callback dispatcher
|
|
32
|
-
# Handle COMPLETED transfers normally; also handle ERROR/TIMED_OUT/CANCELLED
|
|
33
|
-
# so the queues are always drained and dacQueue.put() never deadlocks.
|
|
34
|
-
for status in (
|
|
35
|
-
usb1.TRANSFER_COMPLETED,
|
|
36
|
-
usb1.TRANSFER_ERROR,
|
|
37
|
-
usb1.TRANSFER_TIMED_OUT,
|
|
38
|
-
usb1.TRANSFER_CANCELLED,
|
|
39
|
-
usb1.TRANSFER_STALL,
|
|
40
|
-
usb1.TRANSFER_NO_DEVICE,
|
|
41
|
-
usb1.TRANSFER_OVERFLOW,
|
|
42
|
-
):
|
|
43
|
-
self.transfer_helper.setEventCallback(status, self.callback)
|
|
44
|
-
# self.received_data = bytearray() # Collection of received data bytes
|
|
45
|
-
self.thread = None
|
|
46
|
-
self.running = False
|
|
47
|
-
self._pending_transfers = [] # track all submitted transfers for cancellation
|
|
48
|
-
self._force_stop = (
|
|
49
|
-
threading.Event()
|
|
50
|
-
) # raised to break the worker out of stuck queues
|
|
51
|
-
|
|
52
|
-
def start(self):
|
|
53
|
-
"""
|
|
54
|
-
Starts the streaming and spawns the worker thread.
|
|
55
|
-
"""
|
|
56
|
-
self._force_stop.clear()
|
|
57
|
-
self.thread = threading.Thread(target=self.worker, daemon=True)
|
|
58
|
-
self.running = True
|
|
59
|
-
self.thread.start()
|
|
60
|
-
self.received_data = bytearray()
|
|
61
|
-
self.registers.write(8, 0x05) # Start streaming
|
|
62
|
-
|
|
63
|
-
def stop(self):
|
|
64
|
-
"""
|
|
65
|
-
Stops the streaming and ends the worker thread.
|
|
66
|
-
|
|
67
|
-
Sets ``running`` to False and cancels in-flight USB transfers so their
|
|
68
|
-
callbacks fire and drain the queues, which allows the worker loop to
|
|
69
|
-
exit naturally. If the worker has not exited within 5 seconds (e.g.
|
|
70
|
-
because a USB cancellation is slow), ``_force_stop`` is set so the
|
|
71
|
-
worker breaks unconditionally after its current
|
|
72
|
-
``handleEventsTimeout(0.1)`` call (≤ 100 ms), and we give it one
|
|
73
|
-
further second to join before proceeding.
|
|
74
|
-
"""
|
|
75
|
-
self.running = False
|
|
76
|
-
# Cancel any in-flight transfers so their callbacks fire and drain the queues,
|
|
77
|
-
# allowing the worker thread to exit its loop.
|
|
78
|
-
for t in self._pending_transfers:
|
|
79
|
-
try:
|
|
80
|
-
t.cancel()
|
|
81
|
-
except Exception:
|
|
82
|
-
pass
|
|
83
|
-
self._pending_transfers.clear()
|
|
84
|
-
if self.thread is not None and self.thread.is_alive():
|
|
85
|
-
self.thread.join(timeout=5.0)
|
|
86
|
-
if self.thread.is_alive():
|
|
87
|
-
# Worker is stuck (slow USB cancellation). Force it out so
|
|
88
|
-
# context.close() does not deadlock.
|
|
89
|
-
self._force_stop.set()
|
|
90
|
-
self.thread.join(timeout=1.0)
|
|
91
|
-
self.registers.write(8, 0x00) # Stop streaming
|
|
92
|
-
|
|
93
|
-
def write(self, buffer):
|
|
94
|
-
"""
|
|
95
|
-
Adds a buffer to the playback queue.
|
|
96
|
-
|
|
97
|
-
Args:
|
|
98
|
-
buffer (bytes): The data buffer to be written.
|
|
99
|
-
"""
|
|
100
|
-
transfer = self.device.getTransfer()
|
|
101
|
-
transfer.setBulk(self.endpoint_write, buffer, self.transfer_helper, None, 1000)
|
|
102
|
-
transfer.submit() # Asynchronous transfer
|
|
103
|
-
self._pending_transfers.append(transfer)
|
|
104
|
-
self.dacQueue.put(transfer) # It doesn't matter what we put in here
|
|
105
|
-
|
|
106
|
-
# Submit a USB bulk transfer to read
|
|
107
|
-
read_transfer = self.device.getTransfer()
|
|
108
|
-
read_transfer.setBulk(
|
|
109
|
-
self.endpoint_read, 16384, self.transfer_helper, None, 1000
|
|
110
|
-
)
|
|
111
|
-
read_transfer.submit() # Asynchronous transfer
|
|
112
|
-
self._pending_transfers.append(read_transfer)
|
|
113
|
-
self.adcQueue.put(read_transfer) # It doesn't matter what we put in here
|
|
114
|
-
|
|
115
|
-
def worker(self):
|
|
116
|
-
"""
|
|
117
|
-
Event loop for the asynchronous transfers.
|
|
118
|
-
|
|
119
|
-
Runs until *both* conditions are true:
|
|
120
|
-
* ``running`` is False (``stop()`` has been called), and
|
|
121
|
-
* both USB queues are empty (all callbacks have fired).
|
|
122
|
-
|
|
123
|
-
An early exit is also triggered by ``_force_stop`` (set by ``stop()``
|
|
124
|
-
when the normal join times out) so that ``context.close()`` is never
|
|
125
|
-
called while this thread is still inside ``handleEventsTimeout``.
|
|
126
|
-
"""
|
|
127
|
-
while self.running or not (
|
|
128
|
-
self.dacQueue.empty() and self.adcQueue.empty()
|
|
129
|
-
): # Play until the last
|
|
130
|
-
if self._force_stop.is_set():
|
|
131
|
-
break
|
|
132
|
-
self.context.handleEventsTimeout(
|
|
133
|
-
0.1
|
|
134
|
-
) # 100 ms timeout prevents blocking forever
|
|
135
|
-
|
|
136
|
-
def callback(self, transfer):
|
|
137
|
-
"""
|
|
138
|
-
Callback of the worker thread to handle completed transfers.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
transfer (usb1.USBTransfer): The USB transfer that has completed.
|
|
142
|
-
"""
|
|
143
|
-
if transfer.getEndpoint() == self.endpoint_read:
|
|
144
|
-
if transfer.getStatus() == usb1.TRANSFER_COMPLETED:
|
|
145
|
-
self.received_data.extend(
|
|
146
|
-
transfer.getBuffer()
|
|
147
|
-
) # Collect received data bytes
|
|
148
|
-
self.adcQueue.get_nowait() # Always drain the queue
|
|
149
|
-
else:
|
|
150
|
-
self.dacQueue.get_nowait() # Always drain the queue
|
|
151
|
-
|
|
152
|
-
def collect_remaining_adc_data(self):
|
|
153
|
-
"""
|
|
154
|
-
Waits for all remaining ADC transfers to complete and returns the collected data.
|
|
155
|
-
|
|
156
|
-
Returns:
|
|
157
|
-
bytearray: The collected ADC data.
|
|
158
|
-
"""
|
|
159
|
-
while not self.adcQueue.empty():
|
|
160
|
-
time.sleep(0.01)
|
|
161
|
-
|
|
162
|
-
return self.received_data
|
|
163
|
-
|
|
164
|
-
def write_zeros(self, chunk_bytes: int = 16384):
|
|
165
|
-
"""
|
|
166
|
-
Sends a zero-filled DAC buffer of `chunk_bytes` bytes and queues one
|
|
167
|
-
matching ADC read transfer. Functionally identical to write() but
|
|
168
|
-
generates the zero payload internally so no DAC Wave is needed.
|
|
169
|
-
|
|
170
|
-
Args:
|
|
171
|
-
chunk_bytes (int): Number of bytes per transfer (must be a multiple
|
|
172
|
-
of 4). Defaults to 16384 (16 kB) to match the hardware chunk
|
|
173
|
-
size used by write().
|
|
174
|
-
"""
|
|
175
|
-
zeros = bytes(chunk_bytes)
|
|
176
|
-
|
|
177
|
-
dac_transfer = self.device.getTransfer()
|
|
178
|
-
dac_transfer.setBulk(
|
|
179
|
-
self.endpoint_write, zeros, self.transfer_helper, None, 1000
|
|
180
|
-
)
|
|
181
|
-
dac_transfer.submit()
|
|
182
|
-
self._pending_transfers.append(dac_transfer)
|
|
183
|
-
self.dacQueue.put(dac_transfer)
|
|
184
|
-
|
|
185
|
-
read_transfer = self.device.getTransfer()
|
|
186
|
-
read_transfer.setBulk(
|
|
187
|
-
self.endpoint_read, chunk_bytes, self.transfer_helper, None, 1000
|
|
188
|
-
)
|
|
189
|
-
read_transfer.submit()
|
|
190
|
-
self._pending_transfers.append(read_transfer)
|
|
191
|
-
self.adcQueue.put(read_transfer)
|
|
192
|
-
|
|
193
|
-
def collect_adc_exact(self, n_bytes: int) -> bytearray:
|
|
194
|
-
"""
|
|
195
|
-
Blocks until at least `n_bytes` of ADC data have been collected, then
|
|
196
|
-
returns exactly `n_bytes` (any excess is discarded). The stream must
|
|
197
|
-
have been started with start() and all required write_zeros() calls
|
|
198
|
-
must have been submitted before calling this.
|
|
199
|
-
|
|
200
|
-
Args:
|
|
201
|
-
n_bytes (int): Exact number of bytes to return.
|
|
202
|
-
|
|
203
|
-
Returns:
|
|
204
|
-
bytearray: Exactly n_bytes of calibrated ADC data.
|
|
205
|
-
"""
|
|
206
|
-
while len(self.received_data) < n_bytes:
|
|
207
|
-
time.sleep(0.01)
|
|
208
|
-
return bytearray(self.received_data[:n_bytes])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|