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