From a0469f11bfb449dee62e102b4ce51897d4fe17c9 Mon Sep 17 00:00:00 2001 From: Johannes Pohl Date: Tue, 1 May 2018 19:19:08 +0200 Subject: [PATCH] Add SoundCard Support (#433) * add test for sound device lib * add trace for missing portaudio * optimize imports * add test for pyaudio * initial soundcard implementation with health check * enable rx for soundcard * enable tx for soundcard * make sample rate configurable * python 3.4 compat --- src/urh/controller/dialogs/OptionsDialog.py | 1 + .../widgets/DeviceSettingsWidget.py | 2 + src/urh/dev/BackendHandler.py | 24 +++- src/urh/dev/VirtualDevice.py | 3 + src/urh/dev/config.py | 5 + src/urh/dev/native/SoundCard.py | 115 ++++++++++++++++++ tests/device/TestSoundCard.py | 105 ++++++++++++++++ 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/urh/dev/native/SoundCard.py create mode 100644 tests/device/TestSoundCard.py diff --git a/src/urh/controller/dialogs/OptionsDialog.py b/src/urh/controller/dialogs/OptionsDialog.py index 98b17b6754..fcc8b4a211 100644 --- a/src/urh/controller/dialogs/OptionsDialog.py +++ b/src/urh/controller/dialogs/OptionsDialog.py @@ -447,6 +447,7 @@ def on_btn_rebuild_native_clicked(self): @pyqtSlot() def on_btn_health_check_clicked(self): info = ExtensionHelper.perform_health_check() + info += "\n" + BackendHandler.perform_soundcard_health_check() if util.get_windows_lib_path(): info += "\n\n[INFO] Used DLLs from " + util.get_windows_lib_path() diff --git a/src/urh/controller/widgets/DeviceSettingsWidget.py b/src/urh/controller/widgets/DeviceSettingsWidget.py index 2a6a571ce8..73c61f268d 100644 --- a/src/urh/controller/widgets/DeviceSettingsWidget.py +++ b/src/urh/controller/widgets/DeviceSettingsWidget.py @@ -198,6 +198,8 @@ def set_device_ui_items_visibility(self, device_name: str, adjust_gains=True): spinbox.setMaximum(max(conf[key])) spinbox.setSingleStep(conf[key][1] - conf[key][0]) spinbox.auto_update_step_size = False + if "default_" + key in conf: + spinbox.setValue(conf["default_"+key]) else: spinbox.setMinimum(conf[key].start) spinbox.setMaximum(conf[key].stop) diff --git a/src/urh/dev/BackendHandler.py b/src/urh/dev/BackendHandler.py index a5250aca41..f8df3f2b63 100644 --- a/src/urh/dev/BackendHandler.py +++ b/src/urh/dev/BackendHandler.py @@ -84,7 +84,8 @@ class BackendHandler(object): 3) Manage the selection of devices backend """ - DEVICE_NAMES = ("AirSpy R2", "AirSpy Mini", "Bladerf", "FUNcube", "HackRF", "LimeSDR", "RTL-SDR", "RTL-TCP", "SDRPlay", "USRP") + DEVICE_NAMES = ("AirSpy R2", "AirSpy Mini", "Bladerf", "FUNcube", "HackRF", + "LimeSDR", "RTL-SDR", "RTL-TCP", "SDRPlay", "SoundCard", "USRP") def __init__(self): @@ -129,6 +130,14 @@ def __usrp_native_enabled(self) -> bool: except ImportError: return False + @property + def __soundcard_enabled(self) -> bool: + try: + import pyaudio + return True + except ImportError: + return False + @property def __airspy_native_enabled(self) -> bool: try: @@ -234,6 +243,10 @@ def __avail_backends_for_device(self, devname: str): supports_rx, supports_tx = True, False backends.add(Backends.native) + if devname.lower() == "soundcard" and self.__soundcard_enabled: + supports_rx, supports_tx = True, True + backends.add(Backends.native) + return backends, supports_rx, supports_tx def get_backends(self): @@ -243,6 +256,15 @@ def get_backends(self): container = BackendContainer(device_name.lower(), ab, rx_suprt, tx_suprt) self.device_backends[device_name.lower()] = container + @staticmethod + def perform_soundcard_health_check(): + result = "SoundCard -- " + try: + import pyaudio + return result + "OK" + except Exception as e: + return result + str(e) + def __get_python2_interpreter(self): paths = os.get_exec_path() diff --git a/src/urh/dev/VirtualDevice.py b/src/urh/dev/VirtualDevice.py index b6c0306137..23fcdc98b8 100644 --- a/src/urh/dev/VirtualDevice.py +++ b/src/urh/dev/VirtualDevice.py @@ -115,6 +115,9 @@ def __init__(self, backend_handler, name: str, mode: Mode, freq=None, sample_rat from urh.dev.native.SDRPlay import SDRPlay self.__dev = SDRPlay(freq, gain, bandwidth, gain, if_gain=if_gain, resume_on_full_receive_buffer=resume_on_full_receive_buffer) + elif name == "soundcard": + from urh.dev.native.SoundCard import SoundCard + self.__dev = SoundCard(sample_rate, resume_on_full_receive_buffer=resume_on_full_receive_buffer) else: raise NotImplementedError("Native Backend for {0} not yet implemented".format(name)) diff --git a/src/urh/dev/config.py b/src/urh/dev/config.py index b57d19f808..933ca5edd5 100644 --- a/src/urh/dev/config.py +++ b/src/urh/dev/config.py @@ -106,6 +106,11 @@ "rx_antenna_default_index": 0, } +DEVICE_CONFIG["SoundCard"] = { + "sample_rate": [16e3, 22.05e3, 24e3, 32e3, 44.1e3, 48e3, 96e3, 192e3], + "default_sample_rate": 48e3, +} + DEVICE_CONFIG["Fallback"] = { "center_freq": dev_range(start=1*M, stop=6 * G, step=1), "sample_rate": dev_range(start=2 * M, stop=20 * M, step=1), diff --git a/src/urh/dev/native/SoundCard.py b/src/urh/dev/native/SoundCard.py new file mode 100644 index 0000000000..85e9bec21b --- /dev/null +++ b/src/urh/dev/native/SoundCard.py @@ -0,0 +1,115 @@ +from collections import OrderedDict +from multiprocessing import Array +from multiprocessing.connection import Connection + +import numpy as np +import pyaudio + +from urh.dev.native.Device import Device +from urh.util.Logger import logger + + +class SoundCard(Device): + DEVICE_LIB = pyaudio + ASYNCHRONOUS = False + DEVICE_METHODS = dict() + + CHUNK_SIZE = 1024 + SYNC_TX_CHUNK_SIZE = 2 * CHUNK_SIZE + + SAMPLE_RATE = 48000 + + pyaudio_handle = None + pyaudio_stream = None + + @classmethod + def init_device(cls, ctrl_connection: Connection, is_tx: bool, parameters: OrderedDict) -> bool: + try: + cls.SAMPLE_RATE = int(parameters[cls.Command.SET_SAMPLE_RATE.name]) + except (KeyError, ValueError): + pass + return super().init_device(ctrl_connection, is_tx, parameters) + + @classmethod + def setup_device(cls, ctrl_connection: Connection, device_identifier): + ctrl_connection.send("Initializing pyaudio...") + try: + cls.pyaudio_handle = pyaudio.PyAudio() + ctrl_connection.send("Initialized pyaudio") + return True + except Exception as e: + logger.exception(e) + ctrl_connection.send("Failed to initialize pyaudio") + + @classmethod + def prepare_sync_receive(cls, ctrl_connection: Connection): + try: + cls.pyaudio_stream = cls.pyaudio_handle.open(format=pyaudio.paFloat32, + channels=2, + rate=cls.SAMPLE_RATE, + input=True, + frames_per_buffer=cls.CHUNK_SIZE) + ctrl_connection.send("Successfully started pyaudio stream") + return 0 + except Exception as e: + logger.exception(e) + ctrl_connection.send("Failed to start pyaudio stream") + + @classmethod + def prepare_sync_send(cls, ctrl_connection: Connection): + try: + cls.pyaudio_stream = cls.pyaudio_handle.open(format=pyaudio.paFloat32, + channels=2, + rate=cls.SAMPLE_RATE, + output=True) + ctrl_connection.send("Successfully started pyaudio stream") + return 0 + except Exception as e: + logger.exception(e) + ctrl_connection.send("Failed to start pyaudio stream") + + @classmethod + def receive_sync(cls, data_conn: Connection): + if cls.pyaudio_stream: + data_conn.send_bytes(cls.pyaudio_stream.read(cls.CHUNK_SIZE)) + + @classmethod + def send_sync(cls, data): + if cls.pyaudio_stream: + cls.pyaudio_stream.write(data.tostring()) + + @classmethod + def shutdown_device(cls, ctrl_connection, is_tx: bool): + logger.debug("shutting down pyaudio...") + try: + if cls.pyaudio_stream: + cls.pyaudio_stream.stop_stream() + cls.pyaudio_stream.close() + if cls.pyaudio_handle: + cls.pyaudio_handle.terminate() + ctrl_connection.send("CLOSE:0") + except Exception as e: + logger.exception(e) + ctrl_connection.send("Failed to shut down pyaudio") + + def __init__(self, sample_rate, resume_on_full_receive_buffer=False): + super().__init__(center_freq=0, sample_rate=sample_rate, bandwidth=0, + gain=1, if_gain=1, baseband_gain=1, + resume_on_full_receive_buffer=resume_on_full_receive_buffer) + + self.success = 0 + + @property + def device_parameters(self) -> OrderedDict: + return OrderedDict([(self.Command.SET_SAMPLE_RATE.name, self.sample_rate)]) + + @staticmethod + def unpack_complex(buffer): + return np.frombuffer(buffer, dtype=np.complex64) + + @staticmethod + def pack_complex(complex_samples: np.ndarray): + arr = Array("f", 2*len(complex_samples), lock=False) + numpy_view = np.frombuffer(arr, dtype=np.float32) + numpy_view[:] = complex_samples.view(np.float32) + return arr diff --git a/tests/device/TestSoundCard.py b/tests/device/TestSoundCard.py new file mode 100644 index 0000000000..f6d1c7c890 --- /dev/null +++ b/tests/device/TestSoundCard.py @@ -0,0 +1,105 @@ +import numpy as np + + +def test_sounddevice_lib(): + import time + + import numpy as np + from sounddevice import InputStream, OutputStream, sleep as sd_sleep + """ + if no portaudio installed: + Traceback (most recent call last): + File "TestSoundCard.py", line 42, in + test_sounddevice_lib() + File "TestSoundCard.py", line 5, in test_sounddevice_lib + import sounddevice as sd + File "/usr/lib/python3.6/site-packages/sounddevice.py", line 64, in + raise OSError('PortAudio library not found') + OSError: PortAudio library not found + + """ + + duration = 2.5 # seconds + + rx_buffer = np.ones((10 ** 6, 2), dtype=np.float32) + global current_rx, current_tx + current_rx = 0 + current_tx = 0 + + def rx_callback(indata: np.ndarray, frames: int, time, status): + global current_rx + if status: + print(status) + + rx_buffer[current_rx:current_rx + frames] = indata + current_rx += frames + + def tx_callback(outdata: np.ndarray, frames: int, time, status): + global current_tx + if status: + print(status) + + outdata[:] = rx_buffer[current_tx:current_tx + frames] + current_tx += frames + + with InputStream(channels=2, callback=rx_callback): + sd_sleep(int(duration * 1000)) + + print("Current rx", current_rx) + + with OutputStream(channels=2, callback=tx_callback): + sd_sleep(int(duration * 1000)) + + print("Current tx", current_tx) + + +def test_pyaudio(): + import pyaudio + + CHUNK = 1024 + p = pyaudio.PyAudio() + + stream = p.open(format=pyaudio.paFloat32, + channels=2, + rate=48000, + input=True, + frames_per_buffer=CHUNK) + + print("* recording") + + frames = [] + + for i in range(0, 100): + data = stream.read(CHUNK) + frames.append(data) + + print("* done recording") + + stream.stop_stream() + stream.close() + p.terminate() + data = b''.join(frames) + + print("* playing") + + p = pyaudio.PyAudio() + stream = p.open(format=pyaudio.paFloat32, + channels=2, + rate=48000, + output=True, + ) + + for i in range(0, len(data), CHUNK): + stream.write(data[i:i+CHUNK]) + + stream.stop_stream() + stream.close() + + p.terminate() + + print("* done playing") + + +if __name__ == '__main__': + # test_sounddevice_lib() + test_pyaudio()