PyQa40x-i2c 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyQa40x/__init__.py +18 -0
- PyQa40x/_version.py +24 -0
- PyQa40x/analyzer.py +322 -0
- PyQa40x/analyzer_params.py +66 -0
- PyQa40x/bluetooth.py +134 -0
- PyQa40x/control.py +137 -0
- PyQa40x/cross_correlation.py +80 -0
- PyQa40x/fft_processor.py +93 -0
- PyQa40x/freq_series_plotter.py +81 -0
- PyQa40x/helpers.py +163 -0
- PyQa40x/math_chirp.py +122 -0
- PyQa40x/math_energy.py +36 -0
- PyQa40x/math_general.py +28 -0
- PyQa40x/math_sines.py +126 -0
- PyQa40x/math_windows.py +64 -0
- PyQa40x/registers.py +43 -0
- PyQa40x/series_plotter.py +191 -0
- PyQa40x/sig_proc.py +62 -0
- PyQa40x/stream.py +180 -0
- PyQa40x/wave.py +277 -0
- PyQa40x/wave_chirp.py +175 -0
- PyQa40x/wave_sine.py +85 -0
- pyqa40x_i2c-0.1.0.dist-info/METADATA +110 -0
- pyqa40x_i2c-0.1.0.dist-info/RECORD +27 -0
- pyqa40x_i2c-0.1.0.dist-info/WHEEL +5 -0
- pyqa40x_i2c-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyqa40x_i2c-0.1.0.dist-info/top_level.txt +1 -0
PyQa40x/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# __init__.py in PyQa40x
|
|
2
|
+
|
|
3
|
+
from .analyzer import Analyzer
|
|
4
|
+
from .analyzer_params import AnalyzerParams
|
|
5
|
+
from .wave_sine import Wave, WaveSine
|
|
6
|
+
from .registers import Registers
|
|
7
|
+
from .control import Control
|
|
8
|
+
from .stream import Stream
|
|
9
|
+
from .series_plotter import SeriesPlotter
|
|
10
|
+
from .freq_series_plotter import FreqSeriesPlotter
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from ._version import version as __version__
|
|
14
|
+
except ImportError:
|
|
15
|
+
__version__ = "unknown"
|
|
16
|
+
from .fft_processor import FFTProcessor
|
|
17
|
+
from .sig_proc import SigProc
|
|
18
|
+
from .helpers import *
|
PyQa40x/_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.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
PyQa40x/analyzer.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import usb1 # pip install libusb1
|
|
3
|
+
import struct
|
|
4
|
+
import atexit
|
|
5
|
+
import scipy.signal # pip install scipy
|
|
6
|
+
|
|
7
|
+
from PyQa40x.registers import Registers
|
|
8
|
+
from PyQa40x.control import Control
|
|
9
|
+
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
|
+
from PyQa40x.helpers import *
|
|
14
|
+
from PyQa40x.analyzer_params import AnalyzerParams
|
|
15
|
+
from PyQa40x.bluetooth import BluetoothAudioDevice
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Analyzer:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
"""
|
|
21
|
+
Initializes the Analyzer class.
|
|
22
|
+
"""
|
|
23
|
+
self.params: AnalyzerParams | None = None
|
|
24
|
+
self.context: usb1.USBContext | None = None
|
|
25
|
+
self.device: usb1.USBDeviceHandle | None = None
|
|
26
|
+
self.registers: Registers | None = None
|
|
27
|
+
self.control: Control | None = None
|
|
28
|
+
self.stream: Stream | None = None
|
|
29
|
+
self.cal_data: dict | None = None
|
|
30
|
+
self.bt_device: BluetoothAudioDevice | None = None
|
|
31
|
+
self._cleanup_registered = False
|
|
32
|
+
|
|
33
|
+
def init(
|
|
34
|
+
self,
|
|
35
|
+
sample_rate: int = 48000,
|
|
36
|
+
max_input_level: int = 0,
|
|
37
|
+
max_output_level: int = 18,
|
|
38
|
+
pre_buf: int = 2048,
|
|
39
|
+
post_buf: int = 2048,
|
|
40
|
+
fft_size: int = 16384,
|
|
41
|
+
window_type: str = "boxcar",
|
|
42
|
+
amplitude_unit: str = "dbv",
|
|
43
|
+
distortion_unit: str = "db",
|
|
44
|
+
bt_device_name: str = "",
|
|
45
|
+
) -> AnalyzerParams:
|
|
46
|
+
"""
|
|
47
|
+
Initializes the analyzer hardware with the specified parameters.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
sample_rate (int): The sample rate for the device. Default is 48000. Valid values are 48000, 96000, 192000.
|
|
51
|
+
max_input_level (int): Maximum input level in dBV. Valid values are 0, 6, 12, 18, 24, 30, 36, 42.
|
|
52
|
+
max_output_level (int): Maximum output level in dBV. Valid values are 18, 8, -2, -12.
|
|
53
|
+
pre_buf (int): Size of the pre-buffer.
|
|
54
|
+
post_buf (int): Size of the post-buffer.
|
|
55
|
+
fft_size (int): Size of the FFT.
|
|
56
|
+
window_type (str): Type of window function to apply to the signal before FFT.
|
|
57
|
+
amplitude_unit (str): Unit for amplitude measurements, default is "dbv".
|
|
58
|
+
distortion_unit (str): Unit for distortion measurements, default is "db".
|
|
59
|
+
bt_device_name (str): Name of the Bluetooth device to use. Default is empty.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
AnalyzerParams: A class instance containing the hardware and parameter settings.
|
|
63
|
+
"""
|
|
64
|
+
self.params = AnalyzerParams(
|
|
65
|
+
sample_rate,
|
|
66
|
+
max_input_level,
|
|
67
|
+
max_output_level,
|
|
68
|
+
pre_buf,
|
|
69
|
+
post_buf,
|
|
70
|
+
fft_size,
|
|
71
|
+
window_type,
|
|
72
|
+
)
|
|
73
|
+
self.context = usb1.USBContext()
|
|
74
|
+
self.context.open()
|
|
75
|
+
|
|
76
|
+
# Attempt to open QA402 or QA403 device
|
|
77
|
+
self.device = self.context.openByVendorIDAndProductID(0x16C0, 0x4E37) # QA402
|
|
78
|
+
if self.device is None:
|
|
79
|
+
self.device = self.context.openByVendorIDAndProductID(
|
|
80
|
+
0x16C0, 0x4E39
|
|
81
|
+
) # QA403
|
|
82
|
+
if self.device is None:
|
|
83
|
+
raise SystemExit("No QA402/QA403 analyzer found")
|
|
84
|
+
self.device.resetDevice()
|
|
85
|
+
self.device.claimInterface(0)
|
|
86
|
+
|
|
87
|
+
self.registers = Registers(self.device)
|
|
88
|
+
self.control = Control(self.registers)
|
|
89
|
+
self.stream = Stream(self.context, self.device, self.registers)
|
|
90
|
+
|
|
91
|
+
# Load calibration data
|
|
92
|
+
self.cal_data = self.control.load_calibration()
|
|
93
|
+
|
|
94
|
+
# Set input/output levels and sample rate
|
|
95
|
+
self.control.set_input(max_input_level)
|
|
96
|
+
self.control.set_output(max_output_level)
|
|
97
|
+
self.control.set_samplerate(sample_rate)
|
|
98
|
+
|
|
99
|
+
# Initialize Bluetooth device if a name is provided
|
|
100
|
+
if bt_device_name:
|
|
101
|
+
self.bt_device = BluetoothAudioDevice(bt_device_name, sample_rate)
|
|
102
|
+
if self.bt_device.device_index is None:
|
|
103
|
+
raise SystemExit("Failed to connect to the specified Bluetooth device")
|
|
104
|
+
|
|
105
|
+
# Register cleanup function to be called on exit
|
|
106
|
+
if not self._cleanup_registered:
|
|
107
|
+
atexit.register(self.cleanup)
|
|
108
|
+
self._cleanup_registered = True
|
|
109
|
+
|
|
110
|
+
return self.params
|
|
111
|
+
|
|
112
|
+
def cleanup(self):
|
|
113
|
+
"""
|
|
114
|
+
Releases the USB device interface and closes the context.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
if self.device:
|
|
118
|
+
self.device.releaseInterface(0)
|
|
119
|
+
self.device = None
|
|
120
|
+
if self.bt_device:
|
|
121
|
+
self.bt_device.close()
|
|
122
|
+
self.bt_device = None
|
|
123
|
+
if self.context:
|
|
124
|
+
self.context.close()
|
|
125
|
+
self.context = None
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"An error occurred during cleanup: {e}")
|
|
128
|
+
|
|
129
|
+
def send_receive(self, left_wave: Wave, right_wave: Wave) -> tuple[Wave, Wave]:
|
|
130
|
+
"""
|
|
131
|
+
Sends DAC data to the device and receives ADC data.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
left_wave (Wave): Wave instance with left channel DAC data.
|
|
135
|
+
right_wave (Wave): Wave instance with right channel DAC data.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
tuple[Wave, Wave]: Tuple containing Wave instances with left and right channel ADC data.
|
|
139
|
+
"""
|
|
140
|
+
left_dac_data = left_wave.get_buffer()
|
|
141
|
+
right_dac_data = right_wave.get_buffer()
|
|
142
|
+
|
|
143
|
+
# On QA402 and QA403, outputs need to be swapped
|
|
144
|
+
left_dac_data, right_dac_data = right_dac_data, left_dac_data
|
|
145
|
+
|
|
146
|
+
left_peak = np.max(left_dac_data)
|
|
147
|
+
|
|
148
|
+
# Get calibration factors for outgoing data
|
|
149
|
+
cal_dac_left, cal_dac_right = self.control.get_dac_cal(
|
|
150
|
+
self.cal_data, self.params.max_output_level
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Convert to dBFS. Note the 3 dB adjustment--dBFS is peak, while dBV is RMS
|
|
154
|
+
dac_dbfs_adjustment = 10 ** -((self.params.max_output_level + 3) / 20)
|
|
155
|
+
|
|
156
|
+
# Apply calibration and dBFS scaling
|
|
157
|
+
left_dac_data = left_dac_data * dac_dbfs_adjustment * cal_dac_left
|
|
158
|
+
right_dac_data = right_dac_data * dac_dbfs_adjustment * cal_dac_right
|
|
159
|
+
|
|
160
|
+
# Convert incoming doubles to float
|
|
161
|
+
left_dac_data_float = left_dac_data.astype(np.float32)
|
|
162
|
+
right_dac_data_float = right_dac_data.astype(np.float32)
|
|
163
|
+
|
|
164
|
+
# Interleave the left and right channels
|
|
165
|
+
interleaved_dac_data = np.empty(
|
|
166
|
+
(left_dac_data_float.size + right_dac_data_float.size,), dtype=np.float32
|
|
167
|
+
)
|
|
168
|
+
interleaved_dac_data[0::2] = left_dac_data_float
|
|
169
|
+
interleaved_dac_data[1::2] = right_dac_data_float
|
|
170
|
+
|
|
171
|
+
# Convert to bytes, multiplying by max int value
|
|
172
|
+
max_int_value = 2**31 - 1
|
|
173
|
+
interleaved_dac_data = (interleaved_dac_data * max_int_value).astype(np.int32)
|
|
174
|
+
|
|
175
|
+
# Pack the data into chunks of 16k bytes
|
|
176
|
+
chunk_size = 16384 # 16k bytes
|
|
177
|
+
num_ints_per_chunk = chunk_size // 4 # 32-bit ints, so 4 bytes per int
|
|
178
|
+
total_chunks = len(interleaved_dac_data) // num_ints_per_chunk
|
|
179
|
+
|
|
180
|
+
self.stream.start()
|
|
181
|
+
|
|
182
|
+
# Play wave on Bluetooth if available
|
|
183
|
+
bt_thread = None
|
|
184
|
+
if self.bt_device:
|
|
185
|
+
# Play both left and right waves on Bluetooth
|
|
186
|
+
bt_thread = self.bt_device.play_wave_background(left_wave, right_wave)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
for i in range(total_chunks):
|
|
190
|
+
chunk = interleaved_dac_data[
|
|
191
|
+
i * num_ints_per_chunk : (i + 1) * num_ints_per_chunk
|
|
192
|
+
]
|
|
193
|
+
buffer = struct.pack("<%di" % len(chunk), *chunk)
|
|
194
|
+
self.stream.write(buffer)
|
|
195
|
+
finally:
|
|
196
|
+
self.stream.stop()
|
|
197
|
+
|
|
198
|
+
# Collect ADC data. This is bytes
|
|
199
|
+
interleaved_adc_data = self.stream.collect_remaining_adc_data()
|
|
200
|
+
|
|
201
|
+
# Wait for the Bluetooth thread to finish after processing
|
|
202
|
+
if bt_thread:
|
|
203
|
+
bt_thread.join()
|
|
204
|
+
|
|
205
|
+
# Convert collected ADC data back to int
|
|
206
|
+
interleaved_adc_data = np.frombuffer(interleaved_adc_data, dtype=np.int32)
|
|
207
|
+
|
|
208
|
+
# Separate interleaved ADC data into left and right channels. This is int
|
|
209
|
+
left_adc_data_int = interleaved_adc_data[0::2]
|
|
210
|
+
right_adc_data_int = interleaved_adc_data[1::2]
|
|
211
|
+
|
|
212
|
+
# Convert left and right channels back to double
|
|
213
|
+
left_adc_data = left_adc_data_int.astype(np.float64) / max_int_value
|
|
214
|
+
right_adc_data = right_adc_data_int.astype(np.float64) / max_int_value
|
|
215
|
+
|
|
216
|
+
# Get calibration factors for incoming data
|
|
217
|
+
cal_adc_left, cal_adc_right = self.control.get_adc_cal(
|
|
218
|
+
self.cal_data, self.params.max_input_level
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Convert from dBFS to dBV. Note the 6 dB factor--the ADC is differential
|
|
222
|
+
adc_dbfs_correction = 10 ** ((self.params.max_input_level - 6) / 20)
|
|
223
|
+
|
|
224
|
+
# Apply calibration and dBFS scaling
|
|
225
|
+
left_adc_data = left_adc_data * cal_adc_left * adc_dbfs_correction
|
|
226
|
+
right_adc_data = right_adc_data * cal_adc_right * adc_dbfs_correction
|
|
227
|
+
|
|
228
|
+
# Ensure the buffer matches the expected shape. Is this needed?
|
|
229
|
+
expected_shape = (
|
|
230
|
+
self.params.pre_buf + self.params.fft_size + self.params.post_buf,
|
|
231
|
+
)
|
|
232
|
+
if left_adc_data.shape[0] != expected_shape[0]:
|
|
233
|
+
left_adc_data = np.resize(left_adc_data, expected_shape)
|
|
234
|
+
if right_adc_data.shape[0] != expected_shape[0]:
|
|
235
|
+
right_adc_data = np.resize(right_adc_data, expected_shape)
|
|
236
|
+
|
|
237
|
+
# Create instances of Wave with the full buffers
|
|
238
|
+
left_wave = Wave(self.params)
|
|
239
|
+
right_wave = Wave(self.params)
|
|
240
|
+
left_wave.set_buffer(left_adc_data)
|
|
241
|
+
right_wave.set_buffer(right_adc_data)
|
|
242
|
+
|
|
243
|
+
return left_wave, right_wave
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def list_audio_devices_by_sample_rate(target_sample_rate: int):
|
|
247
|
+
"""List all available audio devices that support a specific sample rate by calling the method from BluetoothAudioDevice."""
|
|
248
|
+
BluetoothAudioDevice.list_audio_devices_by_sample_rate(target_sample_rate)
|
|
249
|
+
|
|
250
|
+
def capture(self, total_samples: int) -> tuple[np.ndarray, np.ndarray]:
|
|
251
|
+
"""
|
|
252
|
+
Captures ``total_samples`` samples from both ADC input channels without
|
|
253
|
+
playing anything on the DAC outputs. Unlike ``send_receive()``, this
|
|
254
|
+
method:
|
|
255
|
+
|
|
256
|
+
* Starts the USB stream **once** for the entire capture — no stop/restart
|
|
257
|
+
between chunks, so no audio is ever lost mid-recording.
|
|
258
|
+
* Sends zero-filled DAC data (silence) only to satisfy the hardware
|
|
259
|
+
protocol; DAC output remains at 0 V throughout.
|
|
260
|
+
* Returns raw calibrated Volt arrays — **no pre/post guard samples are
|
|
261
|
+
discarded** and the length is exactly ``total_samples``.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
total_samples (int): Number of samples to capture per channel.
|
|
265
|
+
At 48 kHz, 1 sample = 1/48000 s. There is no upper limit
|
|
266
|
+
imposed by this method, but the host must have enough RAM to
|
|
267
|
+
hold the full recording (2 × total_samples × 8 bytes for
|
|
268
|
+
float64).
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
tuple[np.ndarray, np.ndarray]: ``(left_volts, right_volts)`` —
|
|
272
|
+
two float64 arrays each of length ``total_samples``, values
|
|
273
|
+
in Volts (calibrated, same scale as ``send_receive()``).
|
|
274
|
+
"""
|
|
275
|
+
chunk_bytes = 16384 # 16 kB per USB transfer
|
|
276
|
+
bytes_per_stereo_sample = 8 # 2 × int32
|
|
277
|
+
need_bytes = total_samples * bytes_per_stereo_sample
|
|
278
|
+
|
|
279
|
+
# Number of DAC write / ADC read pairs needed to cover need_bytes.
|
|
280
|
+
# Each pair transfers chunk_bytes ADC bytes.
|
|
281
|
+
n_chunks = -(-need_bytes // chunk_bytes) # ceiling division
|
|
282
|
+
|
|
283
|
+
max_int_value = 2**31 - 1
|
|
284
|
+
|
|
285
|
+
# -- single stream start ------------------------------------------------
|
|
286
|
+
self.stream.start()
|
|
287
|
+
try:
|
|
288
|
+
# Submit all zero-DAC / ADC-read pairs up front so the USB host
|
|
289
|
+
# controller can pipeline them; no stop/restart in the middle.
|
|
290
|
+
for _ in range(n_chunks):
|
|
291
|
+
self.stream.write_zeros(chunk_bytes)
|
|
292
|
+
|
|
293
|
+
# Block until we have exactly the bytes we need
|
|
294
|
+
raw_bytes = self.stream.collect_adc_exact(need_bytes)
|
|
295
|
+
finally:
|
|
296
|
+
self.stream.stop()
|
|
297
|
+
# -- stream stopped -----------------------------------------------------
|
|
298
|
+
|
|
299
|
+
# Deserialise interleaved int32 ADC data
|
|
300
|
+
interleaved = np.frombuffer(raw_bytes, dtype=np.int32)
|
|
301
|
+
|
|
302
|
+
left_int = interleaved[0::2]
|
|
303
|
+
right_int = interleaved[1::2]
|
|
304
|
+
|
|
305
|
+
# Trim to exact length (collect_adc_exact already trims, but be safe)
|
|
306
|
+
left_int = left_int[:total_samples]
|
|
307
|
+
right_int = right_int[:total_samples]
|
|
308
|
+
|
|
309
|
+
# Convert int32 → float64 in the range ±1
|
|
310
|
+
left_f = left_int.astype(np.float64) / max_int_value
|
|
311
|
+
right_f = right_int.astype(np.float64) / max_int_value
|
|
312
|
+
|
|
313
|
+
# Apply ADC calibration and dBFS → Volts conversion
|
|
314
|
+
cal_adc_left, cal_adc_right = self.control.get_adc_cal(
|
|
315
|
+
self.cal_data, self.params.max_input_level
|
|
316
|
+
)
|
|
317
|
+
adc_dbfs_correction = 10 ** ((self.params.max_input_level - 6) / 20)
|
|
318
|
+
|
|
319
|
+
left_volts = left_f * cal_adc_left * adc_dbfs_correction
|
|
320
|
+
right_volts = right_f * cal_adc_right * adc_dbfs_correction
|
|
321
|
+
|
|
322
|
+
return left_volts, right_volts
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import scipy.signal # pip install scipy
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
class AnalyzerParams:
|
|
5
|
+
def __init__(self, sample_rate: int = 192000, max_input_level: int = 0, max_output_level: int = 18,
|
|
6
|
+
pre_buf: int = 2048, post_buf: int = 2048, fft_size: int = 16384, window_type='boxcar'):
|
|
7
|
+
"""
|
|
8
|
+
Initializes the AnalyzerParams class with default or specified parameters.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
sample_rate (int): Sample rate for the hardware. Valid values are 48000, 96000, 192000.
|
|
12
|
+
max_input_level (int): Maximum input level in dBV. Valid values are 0, 6, 12, 18, 24, 30, 36, 42.
|
|
13
|
+
max_output_level (int): Maximum output level in dBV. Valid values are 18, 8, -2, -12.
|
|
14
|
+
pre_buf (int): Size of the pre-buffer.
|
|
15
|
+
post_buf (int): Size of the post-buffer.
|
|
16
|
+
fft_size (int): Size of the FFT.
|
|
17
|
+
window_type (str): Type of window function to apply to the signal before FFT.
|
|
18
|
+
"""
|
|
19
|
+
self.sample_rate: int = sample_rate
|
|
20
|
+
self.max_input_level: int = max_input_level
|
|
21
|
+
self.max_output_level: int = max_output_level
|
|
22
|
+
self.pre_buf: int = pre_buf
|
|
23
|
+
self.post_buf: int = post_buf
|
|
24
|
+
self.fft_size: int = fft_size
|
|
25
|
+
|
|
26
|
+
self.window_type = window_type
|
|
27
|
+
self.window = scipy.signal.get_window(self.window_type, self.fft_size)
|
|
28
|
+
mean_w = np.mean(self.window)
|
|
29
|
+
self.ACF = 1 / mean_w
|
|
30
|
+
rms_w = np.sqrt(np.mean(self.window ** 2))
|
|
31
|
+
self.ECF = 1 / rms_w
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Returns a string representation of the AnalyzerParams instance.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
str: String representation of the parameters formatted as a table.
|
|
39
|
+
"""
|
|
40
|
+
params = [
|
|
41
|
+
("Sample Rate", f"{self.sample_rate} Hz"),
|
|
42
|
+
("Max Input Level", f"{self.max_input_level} dBV"),
|
|
43
|
+
("Max Output Level", f"{self.max_output_level} dBV"),
|
|
44
|
+
("Pre Buffer", f"{self.pre_buf}"),
|
|
45
|
+
("Post Buffer", f"{self.post_buf}"),
|
|
46
|
+
("Buffer Size", f"{self.fft_size}"),
|
|
47
|
+
("Duration", f"{self.fft_size/self.sample_rate:0.2f} sec"),
|
|
48
|
+
("Window Type", f"{self.window_type}")
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
col_widths = [max(len(item[i]) for item in params) for i in range(2)]
|
|
52
|
+
col_widths[1] += 5 # Add padding
|
|
53
|
+
|
|
54
|
+
table = "===== ACQUISITION PARAMETERS =====\n"
|
|
55
|
+
rows = len(params)
|
|
56
|
+
cols = 3
|
|
57
|
+
|
|
58
|
+
for row in range(0, rows, cols):
|
|
59
|
+
row_str = ""
|
|
60
|
+
for col in range(cols):
|
|
61
|
+
if row + col < rows:
|
|
62
|
+
name, value = params[row + col]
|
|
63
|
+
row_str += f"{name.ljust(col_widths[0])} : {value.ljust(col_widths[1])} "
|
|
64
|
+
table += row_str.strip() + "\n"
|
|
65
|
+
|
|
66
|
+
return table
|
PyQa40x/bluetooth.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import pyaudio # pip install pyaudio
|
|
2
|
+
import threading
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PyQa40x.wave import Wave
|
|
5
|
+
|
|
6
|
+
class BluetoothAudioDevice:
|
|
7
|
+
def __init__(self, device_name: str, sample_rate: int, debug: bool = False):
|
|
8
|
+
"""
|
|
9
|
+
Initializes the BluetoothAudioDevice.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
device_name (str): Name of the target Bluetooth audio device.
|
|
13
|
+
sample_rate (int): Sample rate for playback.
|
|
14
|
+
debug (bool): If True, print detailed debug information. Default is False.
|
|
15
|
+
"""
|
|
16
|
+
self.device_name = device_name
|
|
17
|
+
self.sample_rate = sample_rate
|
|
18
|
+
self.debug = debug
|
|
19
|
+
self.device_index = None
|
|
20
|
+
self.p = pyaudio.PyAudio()
|
|
21
|
+
|
|
22
|
+
# Attempt to find and open the device
|
|
23
|
+
self.device_index = self._find_device()
|
|
24
|
+
if self.device_index is None:
|
|
25
|
+
print(f"Failed to open audio device '{self.device_name}' with the required sample rate {self.sample_rate} Hz.")
|
|
26
|
+
|
|
27
|
+
def _find_device(self):
|
|
28
|
+
"""Find and return the index of the target audio device if it supports the required sample rate."""
|
|
29
|
+
target_device_index = None
|
|
30
|
+
if self.debug:
|
|
31
|
+
print("Enumerating audio devices...\n")
|
|
32
|
+
|
|
33
|
+
for i in range(self.p.get_device_count()):
|
|
34
|
+
info = self.p.get_device_info_by_index(i)
|
|
35
|
+
if self.debug:
|
|
36
|
+
print(f"Device {i}: {info['name']}")
|
|
37
|
+
|
|
38
|
+
if self.device_name in info['name']:
|
|
39
|
+
if self.debug:
|
|
40
|
+
print(f" Checking device '{info['name']}' for sample rate {self.sample_rate} Hz...")
|
|
41
|
+
try:
|
|
42
|
+
# Attempt to open a stream to check if the sample rate is supported
|
|
43
|
+
stream = self.p.open(format=pyaudio.paFloat32,
|
|
44
|
+
channels=2,
|
|
45
|
+
rate=self.sample_rate,
|
|
46
|
+
output=True,
|
|
47
|
+
output_device_index=i)
|
|
48
|
+
stream.close()
|
|
49
|
+
if self.debug:
|
|
50
|
+
print(f" -> Device {i} supports the required sample rate.")
|
|
51
|
+
if target_device_index is None:
|
|
52
|
+
target_device_index = i
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if self.debug:
|
|
55
|
+
print(f" -> Device {i} does not support the required sample rate. ({str(e)})")
|
|
56
|
+
return target_device_index
|
|
57
|
+
|
|
58
|
+
def play_wave_background(self, left_wave: Wave, right_wave: Wave):
|
|
59
|
+
"""Play left and right Wave objects in the background."""
|
|
60
|
+
if self.device_index is None:
|
|
61
|
+
print("No valid audio device found. Cannot play wave.")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
# Start playing the wave in a separate thread
|
|
65
|
+
thread = threading.Thread(target=self._play_wave, args=(left_wave, right_wave, self.sample_rate, self.device_index))
|
|
66
|
+
thread.start()
|
|
67
|
+
return thread
|
|
68
|
+
|
|
69
|
+
def _play_wave(self, left_wave: Wave, right_wave: Wave, sample_rate: int, device_index: int):
|
|
70
|
+
"""Play a stereo Wave object."""
|
|
71
|
+
stream = self.p.open(format=pyaudio.paFloat32,
|
|
72
|
+
channels=2, # Stereo playback
|
|
73
|
+
rate=sample_rate,
|
|
74
|
+
output=True,
|
|
75
|
+
output_device_index=device_index)
|
|
76
|
+
|
|
77
|
+
actual_sample_rate = stream._rate
|
|
78
|
+
if self.debug:
|
|
79
|
+
print(f"Requested Sample Rate: {sample_rate}")
|
|
80
|
+
print(f"Actual Sample Rate: {actual_sample_rate}")
|
|
81
|
+
|
|
82
|
+
# Get buffers for left and right waves
|
|
83
|
+
left_buffer = left_wave.get_buffer().astype(np.float32)
|
|
84
|
+
right_buffer = right_wave.get_buffer().astype(np.float32)
|
|
85
|
+
|
|
86
|
+
# Ensure both buffers are the same length
|
|
87
|
+
min_length = min(len(left_buffer), len(right_buffer))
|
|
88
|
+
left_buffer = left_buffer[:min_length]
|
|
89
|
+
right_buffer = right_buffer[:min_length]
|
|
90
|
+
|
|
91
|
+
# Interleave left and right buffers for stereo output
|
|
92
|
+
interleaved = np.empty((min_length * 2,), dtype=np.float32)
|
|
93
|
+
interleaved[0::2] = left_buffer
|
|
94
|
+
interleaved[1::2] = right_buffer
|
|
95
|
+
|
|
96
|
+
# Convert wave data to float32 format and play
|
|
97
|
+
stream.write(interleaved.tobytes())
|
|
98
|
+
stream.stop_stream()
|
|
99
|
+
stream.close()
|
|
100
|
+
|
|
101
|
+
def close(self):
|
|
102
|
+
"""Terminate the PyAudio instance."""
|
|
103
|
+
self.p.terminate()
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def list_audio_devices():
|
|
107
|
+
"""List all available audio devices by name."""
|
|
108
|
+
p = pyaudio.PyAudio()
|
|
109
|
+
print("Available audio devices:\n")
|
|
110
|
+
for i in range(p.get_device_count()):
|
|
111
|
+
info = p.get_device_info_by_index(i)
|
|
112
|
+
print(f"Device {i}: {info['name']}")
|
|
113
|
+
p.terminate()
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def list_audio_devices_by_sample_rate(target_sample_rate: int):
|
|
117
|
+
"""List all available audio devices that support a specific sample rate."""
|
|
118
|
+
p = pyaudio.PyAudio()
|
|
119
|
+
print(f"Audio devices supporting {target_sample_rate} Hz sample rate:\n")
|
|
120
|
+
for i in range(p.get_device_count()):
|
|
121
|
+
info = p.get_device_info_by_index(i)
|
|
122
|
+
try:
|
|
123
|
+
# Check if the device supports the specified sample rate
|
|
124
|
+
stream = p.open(format=pyaudio.paFloat32,
|
|
125
|
+
channels=2, # Check for stereo support
|
|
126
|
+
rate=target_sample_rate,
|
|
127
|
+
output=True,
|
|
128
|
+
output_device_index=i)
|
|
129
|
+
stream.close()
|
|
130
|
+
print(f"Device {i}: {info['name']} supports {target_sample_rate} Hz")
|
|
131
|
+
except Exception:
|
|
132
|
+
# Device does not support the specified sample rate
|
|
133
|
+
continue
|
|
134
|
+
p.terminate()
|