├── .gitignore ├── LICENSE ├── Pipfile ├── README.md ├── build.py ├── data └── _sounddevice_data │ ├── __init__.py │ └── portaudio-binaries │ ├── README.md │ └── libportaudio64bit.dll ├── model.jit ├── model_micro.jit ├── model_micro_8k.jit ├── pysoundboard.py ├── pysoundfilter.pyw ├── settings.py ├── setup.py ├── soundfilter.py └── spectrum_analyzer.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, harryhecanada 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | sounddevice = "*" 8 | pyqt5 = "*" 9 | numpy = "*" 10 | matplotlib = "*" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### python-soundfilter 2 | Quick and dirty spectrum analyzer and audio filter for Windows using the sounddevice library. Currently is able to be used to filter out noise, keyboard typing, and provide a sound level based activation filter for VoIP use. Future development may contain an AI algorithm for more advanced filtering. 3 | ### Pre-requisites 4 | Download and install https://www.vb-audio.com/Cable/ in order to create a virtual microphone to be used by VoIP applications to receive your filtered audio. 5 | ### Quick Start Guide 6 | 1) Download a copy of the filter from releases: https://github.com/harryhecanada/python-soundfilter/releases 7 | 2) Run the application 8 | 3) Change target application's input device to CABLE Output (Requires the Virtual Audio Cable!) 9 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | from PyInstaller.__main__ import run 2 | import pathlib 3 | import shutil 4 | 5 | build_folder = pathlib.Path('build') 6 | dist_folder = pathlib.Path('dist') 7 | data_folder = pathlib.Path('data') 8 | pa_dll = pathlib.Path('_sounddevice_data/portaudio-binaries/libportaudio64bit.dll') 9 | 10 | if build_folder.exists(): 11 | shutil.rmtree(build_folder) 12 | 13 | if dist_folder.exists(): 14 | shutil.rmtree(dist_folder) 15 | 16 | run(['--onefile', '--noconsole', '--add-binary', str(data_folder/pa_dll)+";"+str(pa_dll.parent), 'pysoundfilter.pyw']) -------------------------------------------------------------------------------- /data/_sounddevice_data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryhecanada/python-soundfilter/4bfa744433517a8f1f4f9d10fa617a9b04ad9c33/data/_sounddevice_data/__init__.py -------------------------------------------------------------------------------- /data/_sounddevice_data/portaudio-binaries/README.md: -------------------------------------------------------------------------------- 1 | PortAudio binaries 2 | ================== 3 | 4 | This repository provides pre-compiled dynamic libraries for 5 | [PortAudio](http://www.portaudio.com/). 6 | 7 | DLLs for Windows (32-bit and 64-bit) 8 | ------------------------------------ 9 | 10 | The DLLs include all available host APIs, namely WMME, DirectSound, WDM/KS, 11 | WASAPI and ASIO. For more informaton about the ASIO SDK see 12 | http://www.steinberg.net/en/company/developers.html. 13 | 14 | The DLLs were created on a Debian GNU/Linux system using [MXE](http://mxe.cc/) 15 | ([this version](https://github.com/mxe/mxe/tree/87b08fae68f5e45263d1bb71e8061700d86e3c8c), using `pa_stable_v190600_20161030.tgz`) 16 | with the following commands (after installing the 17 | [dependencies](http://mxe.cc/#requirements)): 18 | 19 | git clone https://github.com/mxe/mxe.git 20 | wget http://www.steinberg.net/sdk_downloads/asiosdk2.3.zip 21 | export PATH=$(pwd)"/mxe/usr/bin:$PATH" 22 | 23 | Open the file `mxe/src/portaudio.mk` and change 24 | `--with-winapi=wmme,directx,wdmks,wasapi` to 25 | `--with-winapi=wmme,directx,wdmks,wasapi,asio` (and make sure to keep the 26 | backslash at the end of the line). 27 | To the first line starting with "$(MAKE)", append " EXAMPLES= SELFTESTS=" (without the quotes). 28 | Delete the 4 lines before the last line (i.e. keep the line with "endef"). 29 | After saving your changes, please continue: 30 | 31 | for TARGET in x86_64-w64-mingw32.static i686-w64-mingw32.static 32 | do 33 | unzip asiosdk2.3.zip 34 | # You'll need write access in /usr/local for this: 35 | mv ASIOSDK2.3 /usr/local/asiosdk2 36 | # If it doesn't work, prepend "sudo " to the previous command 37 | make -C mxe portaudio MXE_TARGETS=$TARGET 38 | $TARGET-gcc -O2 -shared -o libportaudio-$TARGET.dll -Wl,--whole-archive -lportaudio -Wl,--no-whole-archive -lstdc++ -lwinmm -lole32 -lsetupapi 39 | $TARGET-strip libportaudio-$TARGET.dll 40 | chmod -x libportaudio-$TARGET.dll 41 | # again, you'll probably have to use "sudo": 42 | rm -r /usr/local/asiosdk2 43 | done 44 | 45 | mv libportaudio-x86_64-w64-mingw32.static.dll libportaudio64bit.dll 46 | mv libportaudio-i686-w64-mingw32.static.dll libportaudio32bit.dll 47 | 48 | A different set of DLLs (compiled with Visual Studio) is available at 49 | https://github.com/adfernandes/precompiled-portaudio-windows. 50 | 51 | dylib for Mac OS X (64-bit) 52 | --------------------------- 53 | 54 | The dylib was created on a Mac OS X system using XCode. 55 | The XCode CLI tools were installed with: 56 | 57 | xcode-select --install 58 | 59 | The following commands were used for compilation: 60 | 61 | curl -O http://www.portaudio.com/archives/pa_stable_v190600_20161030.tgz 62 | tar xvf pa_stable_v190600_20161030.tgz 63 | cd portaudio 64 | # in configure: replace "-Werror" (just search for it) with "-DNDEBUG" 65 | ./configure --disable-mac-universal MACOSX_DEPLOYMENT_TARGET=10.6 66 | make 67 | cd .. 68 | cp portaudio/lib/.libs/libportaudio.2.dylib libportaudio.dylib 69 | 70 | Copyright 71 | --------- 72 | 73 | * PortAudio by Ross Bencina and Phil Burk, MIT License. 74 | 75 | * Steinberg Audio Stream I/O API by Steinberg Media Technologies GmbH. 76 | -------------------------------------------------------------------------------- /data/_sounddevice_data/portaudio-binaries/libportaudio64bit.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryhecanada/python-soundfilter/4bfa744433517a8f1f4f9d10fa617a9b04ad9c33/data/_sounddevice_data/portaudio-binaries/libportaudio64bit.dll -------------------------------------------------------------------------------- /model.jit: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryhecanada/python-soundfilter/4bfa744433517a8f1f4f9d10fa617a9b04ad9c33/model.jit -------------------------------------------------------------------------------- /model_micro.jit: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryhecanada/python-soundfilter/4bfa744433517a8f1f4f9d10fa617a9b04ad9c33/model_micro.jit -------------------------------------------------------------------------------- /model_micro_8k.jit: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryhecanada/python-soundfilter/4bfa744433517a8f1f4f9d10fa617a9b04ad9c33/model_micro_8k.jit -------------------------------------------------------------------------------- /pysoundboard.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from sys import exit 3 | import pathlib 4 | import pyWinhook 5 | import sounddevice as sd 6 | import soundfile as sf 7 | 8 | ''' 9 | Window 10 | Press button to start binding process: 11 | Ask user to press key to select key to bind 12 | After user press key its locked in and they can browse to a sound file to play, has to be WAV for now 13 | Have a scroll box that lists the key binds currently loaded 14 | Allow user to select and delete from scrollbox with button 15 | Start button 16 | Stop button 17 | Save button 18 | Reset button 19 | Copy device selection from pysoundfilter 20 | Volume Modification 21 | Help button 22 | About button 23 | Version Strings 24 | Minimize and Close buttons 25 | ''' 26 | 27 | debug = True 28 | output_devices = [16, 18] 29 | callbacks = [] 30 | 31 | def play_concurrent(data, samplerate, devices): 32 | for device in devices: 33 | sd.play(data, samplerate, device=device) 34 | callbacks.append(sd._last_callback) 35 | sd._last_callback = None 36 | 37 | def stop_all(ignore_errors=True): 38 | for callback in callbacks: 39 | callback.stream.stop(ignore_errors) 40 | callback.stream.close(ignore_errors) 41 | 42 | class SoundBoardMap(dict): 43 | def preload(self): 44 | for key, item in self.items(): 45 | if isinstance(item, str): 46 | path = pathlib.Path(item) 47 | else: 48 | path = pathlib.Path(item['path']) 49 | 50 | if isinstance(key, int): 51 | print("Preloading ASCII", key, "with", path.name) 52 | else: 53 | print("Preloading Key", key, "with", path.name) 54 | 55 | if path.suffix == "": 56 | #Assume control message and not a file 57 | self[key] = globals()[path.name] 58 | else: 59 | data, samplerate = sf.read(path.resolve(), dtype='float32') 60 | print(data.shape, sum(data), samplerate) 61 | 62 | # Change volume 63 | if float(item['volume']) > 0: 64 | data *= float(item['volume']) 65 | else: 66 | print("Volume setting must be greater than 0, e.g 0.5.") 67 | 68 | # Change duration 69 | if float(item['duration']) > 0 and float(item['duration']) <= 1: 70 | data = data[0:int(len(data)*float(item['duration']))][:] 71 | else: 72 | print("Duration setting must be greater than 0 and less than or equal to 1, e.g. 0.5.") 73 | print(data.shape, sum(data), samplerate) 74 | 75 | self[key] = { 76 | "path":path, 77 | "volume":float(item['volume']), 78 | "duration":float(item['duration']), 79 | "data":data, 80 | "samplerate":samplerate 81 | } 82 | 83 | sound_board_map = SoundBoardMap({ 84 | 3:"exit", 85 | "Numpad0":"stop_all", 86 | "Numpad1":{ 87 | "path":"JohnCena.wav", 88 | "volume":"0.5", 89 | "duration":"0.5" 90 | }, 91 | "Numpad2":{ 92 | "path":"Fail_Recorder_Mission_Impossible_Themesong_online-audio-converter.com.wav", 93 | "volume":"0.5", 94 | "duration":"0.5" 95 | }, 96 | "Numpad3":{ 97 | "path":"Mortal_Kombat_X_Fatality_SOUND_EFFECT_online-audio-converter.com.wav", 98 | "volume":"0.5", 99 | "duration":"0.5" 100 | } 101 | }) 102 | sound_board_map.preload() 103 | 104 | def OnMouseEvent(event): 105 | if debug: 106 | print('MessageName: %s' % event.MessageName) 107 | print('Message: %s' % event.Message) 108 | print('Time: %s' % event.Time) 109 | print('Window: %s' % event.Window) 110 | print('WindowName: %s' % event.WindowName) 111 | print('Position: (%d, %d)' % event.Position) 112 | print('Wheel: %s' % event.Wheel) 113 | print('Injected: %s' % event.Injected) 114 | print('---') 115 | 116 | # return True to pass the event to other handlers 117 | # return False to stop the event from propagating 118 | return True 119 | 120 | def OnKeyboardEvent(event): 121 | if debug: 122 | print('MessageName: %s' % event.MessageName) 123 | print('Message: %s' % event.Message) 124 | print('Time: %s' % event.Time) 125 | print('Window: %s' % event.Window) 126 | print('WindowName: %s' % event.WindowName) 127 | print('Ascii: ', event.Ascii) 128 | print('Key: %s' % event.Key) 129 | print('KeyID: %s' % event.KeyID) 130 | print('ScanCode: %s' % event.ScanCode) 131 | print('Extended: %s' % event.Extended) 132 | print('Injected: %s' % event.Injected) 133 | print('Alt %s' % event.Alt) 134 | print('Transition %s' % event.Transition) 135 | print('---') 136 | 137 | # Ctrl + C exits, TODO: make configurable? 138 | if event.Ascii == 3: 139 | exit() 140 | 141 | if event.Key in sound_board_map or event.Ascii in sound_board_map: 142 | item = sound_board_map[event.Key] 143 | if callable(item): 144 | item() 145 | else: 146 | stop_all() 147 | play_concurrent(item["data"], item['samplerate'], output_devices) 148 | 149 | # return True to pass the event to other handlers 150 | # return False to stop the event from propagatingd 151 | return True 152 | 153 | # create the hook mananger 154 | hm = pyWinhook.HookManager() 155 | # register two callbacks 156 | hm.MouseAllButtonsDown = OnMouseEvent 157 | hm.KeyDown = OnKeyboardEvent 158 | 159 | # hook into the mouse and keyboard events 160 | hm.HookMouse() 161 | hm.HookKeyboard() 162 | 163 | import pythoncom 164 | pythoncom.PumpMessages() -------------------------------------------------------------------------------- /pysoundfilter.pyw: -------------------------------------------------------------------------------- 1 | import sounddevice 2 | import PyQt5.QtWidgets as qw 3 | from soundfilter import SileroSoundFilter 4 | from settings import settings 5 | 6 | 7 | class FilterWindow(qw.QMainWindow): 8 | def __init__(self): 9 | super(FilterWindow, self).__init__() 10 | 11 | # Start a filter object 12 | self.filter = SileroSoundFilter() 13 | 14 | self.originalPalette = qw.QApplication.palette() 15 | 16 | top_layout = qw.QHBoxLayout() 17 | self.save = qw.QPushButton("Save Settings") 18 | self.save.clicked.connect(self.save_settings) 19 | self.load = qw.QPushButton("Load Settings") 20 | self.load.clicked.connect(self.load_settings) 21 | self.default = qw.QPushButton("Load Defaults") 22 | self.default.clicked.connect(self.default_settings) 23 | top_layout.addWidget(self.save) 24 | top_layout.addWidget(self.load) 25 | top_layout.addWidget(self.default) 26 | # config_layout = qw.QGridLayout() 27 | 28 | # config_layout.addWidget(qw.QLabel("Active Voice Level"), 0, 0) 29 | # self.active_voice_level = qw.QDoubleSpinBox() 30 | # self.active_voice_level.setMinimum(0.0) 31 | # self.active_voice_level.valueChanged.connect(self.set_active_voice_level) 32 | # config_layout.addWidget(self.active_voice_level, 1, 0) 33 | 34 | # config_layout.addWidget(qw.QLabel("Active Count"), 0, 1) 35 | # self.active_count = qw.QSpinBox() 36 | # self.active_count.setMinimum(1) 37 | # self.active_count.valueChanged.connect(self.set_active_count) 38 | # config_layout.addWidget(self.active_count, 1, 1) 39 | 40 | # config_layout.addWidget(qw.QLabel("Starting Voice Frequency"), 2, 0) 41 | # self.start_freq = qw.QSpinBox() 42 | # self.start_freq.setMinimum(0) 43 | # self.start_freq.setMaximum(20000) 44 | # self.start_freq.valueChanged.connect(self.set_start_freq) 45 | # config_layout.addWidget(self.start_freq, 3, 0) 46 | 47 | # config_layout.addWidget(qw.QLabel("Ending Voice Frequency"), 2, 1) 48 | # self.end_freq = qw.QSpinBox() 49 | # self.end_freq.setMinimum(0) 50 | # self.end_freq.setMaximum(20000) 51 | # self.end_freq.valueChanged.connect(self.set_end_freq) 52 | # config_layout.addWidget(self.end_freq, 3, 1) 53 | 54 | # Add input APIs 55 | input_layout = qw.QVBoxLayout() 56 | input_layout.addWidget(qw.QLabel("API")) 57 | self.input_devices = qw.QComboBox() 58 | self.input_apis = qw.QComboBox() 59 | self.input_apis.setCurrentIndex(settings['input-api']) 60 | self.set_input_api(settings['input-api']) 61 | self.input_apis.activated.connect(self.set_input_api) 62 | for api in sounddevice.query_hostapis(): 63 | self.input_apis.addItem(api.get('name')) 64 | input_layout.addWidget(self.input_apis) 65 | 66 | # Add output APIs 67 | output_layout = qw.QVBoxLayout() 68 | output_layout.addWidget(qw.QLabel("API")) 69 | self.output_devices = qw.QComboBox() 70 | self.output_apis = qw.QComboBox() 71 | self.output_apis.setCurrentIndex(settings['output-api']) 72 | self.set_output_api(settings['output-api']) 73 | self.output_apis.activated.connect(self.set_output_api) 74 | for api in sounddevice.query_hostapis(): 75 | self.output_apis.addItem(api.get('name')) 76 | output_layout.addWidget(self.output_apis) 77 | 78 | # Add input and output devices 79 | self.set_input_device(settings['input-device']) 80 | self.input_devices.setCurrentText(self.filter.input) 81 | self.input_devices.textActivated.connect(self.set_input_device) 82 | 83 | 84 | self.set_output_device(settings['output-device']) 85 | self.output_devices.setCurrentText(self.filter.output) 86 | self.output_devices.textActivated.connect(self.set_output_device) 87 | 88 | input_layout.addWidget(qw.QLabel("Device")) 89 | input_layout.addWidget(self.input_devices) 90 | output_layout.addWidget(qw.QLabel("Device")) 91 | output_layout.addWidget(self.output_devices) 92 | 93 | self.input_group_box = qw.QGroupBox("Inputs") 94 | self.input_group_box.setLayout(input_layout) 95 | self.output_group_box = qw.QGroupBox("Outputs") 96 | self.output_group_box.setLayout(output_layout) 97 | 98 | # Add start stop buttons 99 | bottom_layout = qw.QHBoxLayout() 100 | start_button = qw.QPushButton("Start") 101 | start_button.clicked.connect(self.start) 102 | bottom_layout.addWidget(start_button) 103 | stop_button = qw.QPushButton("Stop") 104 | stop_button.clicked.connect(self.stop) 105 | bottom_layout.addWidget(stop_button) 106 | 107 | # Add everything together 108 | main_layout = qw.QGridLayout() 109 | main_layout.addLayout(top_layout, 0, 0, 1, 3) 110 | # main_layout.addLayout(config_layout, 1, 0, 1, 3) 111 | main_layout.addWidget(self.input_group_box, 2, 0) 112 | main_layout.addWidget(self.output_group_box, 2, 1) 113 | main_layout.addLayout(bottom_layout, 3, 0, 1, 3) 114 | main_layout.setRowStretch(1, 1) 115 | main_layout.setRowStretch(2, 1) 116 | main_layout.setColumnStretch(0, 1) 117 | main_layout.setColumnStretch(1, 1) 118 | 119 | self.load_settings() 120 | 121 | self.central_widget = qw.QWidget() 122 | self.central_widget.setLayout(main_layout) 123 | self.setCentralWidget(self.central_widget) 124 | 125 | self.setWindowTitle("PySoundFilter") 126 | self.change_style('Windows') 127 | 128 | def change_style(self, styleName): 129 | qw.QApplication.setStyle(qw.QStyleFactory.create(styleName)) 130 | 131 | def update_devices(self, kind, api): 132 | if kind == 'input': 133 | self.input_devices.clear() 134 | elif kind == 'output': 135 | self.output_devices.clear() 136 | 137 | devices = sounddevice.query_devices() 138 | for device_id in range(len(devices)): 139 | device = devices[device_id] 140 | if device.get("hostapi") == api: 141 | try: 142 | if kind == 'input': 143 | sounddevice.check_input_settings(device_id) 144 | self.input_devices.addItem(device['name']) 145 | elif kind == 'output': 146 | sounddevice.check_output_settings(device_id) 147 | self.output_devices.addItem(device['name']) 148 | except: 149 | pass 150 | 151 | def start(self): 152 | self.filter.start() 153 | self.setWindowTitle("Running") 154 | 155 | def stop(self): 156 | self.filter.stop() 157 | self.setWindowTitle("Stopped") 158 | 159 | def set_input_api(self, index): 160 | self.update_devices('input', index) 161 | settings['input-api'] = index 162 | 163 | def set_output_api(self, index): 164 | self.update_devices('output', index) 165 | settings['output-api'] = index 166 | 167 | def set_input_device(self, input_device): 168 | self.filter.set_input(input_device, self.input_apis.currentIndex()) 169 | settings['input-device'] = self.filter.input 170 | 171 | def set_output_device(self, output_device): 172 | self.filter.set_output(output_device, self.output_apis.currentIndex()) 173 | settings['output-device'] = self.filter.output 174 | 175 | def set_active_voice_level(self, value): 176 | settings['active-level'] = value 177 | self.filter.active_level = value 178 | if self.filter.stream: 179 | self.start() 180 | 181 | def set_active_count(self, value): 182 | settings['active-count'] = value 183 | self.filter.active_count = value 184 | if self.filter.stream: 185 | self.start() 186 | 187 | def set_start_freq(self, value): 188 | settings['start'] = value 189 | self.filter.start_freq = value 190 | if self.filter.stream: 191 | self.start() 192 | 193 | def set_end_freq(self, value): 194 | settings['end'] = value 195 | self.filter.end_freq = value 196 | if self.filter.stream: 197 | self.start() 198 | 199 | def save_settings(self): 200 | settings.save() 201 | 202 | def load_settings(self): 203 | settings.load() 204 | self.update_settings() 205 | 206 | def update_settings(self): 207 | #Update GUI 208 | # self.active_voice_level.setValue(float(settings['active-level'])) 209 | # self.active_count.setValue(settings['active-count']) 210 | # self.start_freq.setValue(settings['start']) 211 | # self.end_freq.setValue(settings['end']) 212 | 213 | #Update filter parameters 214 | # self.filter.active_level = float(settings['active-level']) 215 | # self.filter.active_count = settings['active-count'] 216 | # self.filter.start_freq = settings['start'] 217 | # self.filter.end_freq = settings['end'] 218 | return 219 | 220 | def default_settings(self): 221 | settings.set_to_defaults() 222 | self.update_settings() 223 | if self.filter.stream: 224 | self.start() 225 | 226 | if __name__ == "__main__": 227 | app = qw.QApplication([]) 228 | fw = FilterWindow() 229 | fw.show() 230 | app.exec_() -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | from PyQt5.QtCore import QSettings 2 | 3 | 4 | class Settings(dict): 5 | __ver__ = "0.0.1" 6 | __company__ = "python_soundfilter" 7 | __product__ = "python_soundfilter" 8 | __defaults = { 9 | "input-api": 0, 10 | "output-api": 0, 11 | "input-device": "microphone", 12 | "output-device": "CABLE Input", 13 | "block-duration": 50, 14 | "active-level": 3.0, 15 | "active-count": 10, 16 | "start": 0, 17 | "end": 20 18 | } 19 | def __init__(self): 20 | super().__init__() 21 | self.load() 22 | 23 | def set_to_defaults(self): 24 | self.update(self.__defaults) 25 | return self 26 | 27 | def load(self, settings:QSettings=QSettings(__company__, __product__)): 28 | if not settings.contains('version'): 29 | self.set_to_defaults() 30 | self.save() 31 | 32 | self.clear() 33 | for key, value in self.__defaults.items(): 34 | self.update({key:settings.value(key, value)}) 35 | 36 | print("Settings loaded:", self) 37 | return self 38 | 39 | def save(self, data:dict = {}, settings:QSettings=QSettings(__company__, __product__)): 40 | if not data: 41 | data = self 42 | for key, item in data.items(): 43 | settings.setValue(key, item) 44 | settings.setValue('version', self.__ver__) 45 | 46 | settings = Settings() -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from distutils.core import setup 3 | 4 | 5 | setup(name='python-soundfilter', 6 | version='0.0.1', 7 | description='Python Sound Filter', 8 | author='Harry He', 9 | url='https://github.com/harryhecanada/python-soundfilter', 10 | install_requires=['sounddevice', 'pyqt5', 'numpy', 'matplotlib'], 11 | scripts=['pysoundfilter.py', 'soundfilter.py', 'spectrum_analyzer.py'] 12 | ) -------------------------------------------------------------------------------- /soundfilter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 4 | """ 5 | import json 6 | import sounddevice as sd 7 | import numpy as np 8 | import torch 9 | model = torch.jit.load('model_micro.jit') 10 | model.eval() 11 | class SoundFilter(object): 12 | def __init__(self, input_device='microphone', output_device='CABLE Input', active_level=3, active_count = 10, start_freq=0, end_freq=20, block_duration = 250): 13 | self._input_id, self._input_device = self.get_device(input_device, 'input') 14 | self._output_id, self._output_device = self.get_device(output_device, 'output') 15 | 16 | # If input device cannot be found, try other names 17 | input_names = ['cam', 'web', 'phone'] 18 | if not self._input_device: 19 | for name in input_names: 20 | self._input_id, self._input_device = self.get_device(name, 'input') 21 | if input_device: 22 | break 23 | 24 | if not input_device: 25 | input_device = sd.query_devices(kind='input') 26 | 27 | if not input_device: 28 | raise ValueError("Unable to find any input device for sound filter to function.") 29 | 30 | print("Input device info:") 31 | print(json.dumps(sd.query_devices(self._input_id), indent = 4)) 32 | 33 | print("Output device info:") 34 | print(json.dumps(sd.query_devices(self._output_id), indent = 4)) 35 | 36 | self.active_counter = 0 37 | self.start_freq = start_freq 38 | self.end_freq = end_freq 39 | self.active_level = active_level 40 | self.active_count = active_count 41 | self.samplerate = self._input_device['default_samplerate'] 42 | self.block_size = int(self.samplerate * block_duration / 1000) 43 | self.stream = None 44 | 45 | import atexit 46 | atexit.register(self.stop) 47 | 48 | def _new_stream(self): 49 | if self.stream: 50 | self.stream.stop() 51 | self.stream = None 52 | self.stream = sd.Stream(device=(self._input_id, self._output_id), samplerate=self.samplerate, blocksize=self.block_size, callback=self.callback) 53 | 54 | @property 55 | def input(self): 56 | return self._input_device['name'] 57 | 58 | @property 59 | def output(self): 60 | return self._output_device['name'] 61 | 62 | def set_input(self, input_device, api = 'MME', block_duration = 50): 63 | self._input_id, self._input_device = self.get_device(input_device, 'input', api) 64 | self.samplerate = self._input_device['default_samplerate'] 65 | self.block_size = int(self.samplerate * block_duration / 1000) 66 | if self.stream: 67 | self.start() 68 | 69 | def set_output(self, output_device, api = 'MME'): 70 | self._output_id, self._output_device = self.get_device(output_device, 'output', api) 71 | if self.stream: 72 | self.start() 73 | 74 | def start(self): 75 | self._new_stream() 76 | self.stream.start() 77 | return self 78 | 79 | def stop(self): 80 | if self.stream: 81 | self.stream.stop() 82 | self.stream = None 83 | return self 84 | 85 | def activation_function(self, data): 86 | if np.mean(data[self.start_freq:self.end_freq]) > self.active_level: 87 | self.active_counter = self.active_count 88 | return True 89 | elif self.active_counter > 0: 90 | self.active_counter -= 1 91 | return True 92 | else: 93 | return False 94 | 95 | def callback(self, indata, outdata, frames, time, status): 96 | # if status: 97 | # print(status) 98 | data = np.fft.rfft(indata[:, 0]) 99 | data = np.abs(data) 100 | 101 | if self.activation_function(data): 102 | outdata[:] = indata 103 | else: 104 | outdata[:] = 0 105 | 106 | @staticmethod 107 | def get_device(name = 'CABLE Input', kind = 'output', api = 0): 108 | if isinstance(name, int): 109 | return name, sd.query_devices(name) 110 | 111 | devices = sd.query_devices() 112 | matching_devices = [] 113 | for device_id in range(len(devices)): 114 | if name.lower() in devices[device_id].get('name').lower(): 115 | try: 116 | if kind == 'input': 117 | sd.check_input_settings(device_id) 118 | elif kind == 'output': 119 | sd.check_output_settings(device_id) 120 | else: 121 | print('Invalid kind') 122 | return None 123 | matching_devices.append((device_id, devices[device_id])) 124 | except: 125 | pass 126 | 127 | if not matching_devices: 128 | print("Unable to find device matching name", name, 'of kind', kind) 129 | return None 130 | 131 | found = False 132 | 133 | if isinstance(api, int): 134 | api = sd.query_hostapis(api).get('name') 135 | for device_id, device in matching_devices: 136 | if api in sd.query_hostapis(int(device.get('hostapi'))).get('name'): 137 | found = True 138 | break 139 | 140 | if not found: 141 | print("Unable to find device matching host api", api, 'using first available...') 142 | return matching_devices[0] 143 | else: 144 | return device_id, device 145 | 146 | class SileroSoundFilter(SoundFilter): 147 | _samplerate = 16000 148 | _block_length = 250 149 | _active_count = 1 150 | def __init__(self, input_device='microphone', output_device='CABLE Input'): 151 | self._input_id, self._input_device = self.get_device(input_device, 'input') 152 | self._output_id, self._output_device = self.get_device(output_device, 'output') 153 | 154 | # If input device cannot be found, try other names 155 | input_names = ['cam', 'web', 'phone'] 156 | if not self._input_device: 157 | for name in input_names: 158 | self._input_id, self._input_device = self.get_device(name, 'input') 159 | if input_device: 160 | break 161 | 162 | if not input_device: 163 | input_device = sd.query_devices(kind='input') 164 | 165 | if not input_device: 166 | raise ValueError("Unable to find any input device for sound filter to function.") 167 | 168 | print("Input device info:") 169 | print(json.dumps(sd.query_devices(self._input_id), indent = 4)) 170 | 171 | print("Output device info:") 172 | print(json.dumps(sd.query_devices(self._output_id), indent = 4)) 173 | 174 | self.samplerate = self._samplerate 175 | # Hard coded block duration of 250ms 176 | self.block_size = int(self._samplerate * self._block_length / 1000) 177 | # Will crash if block size is not 4000, model is not trained for it 178 | assert(self.block_size == 4000) 179 | self.stream = None 180 | self.active_counter = 0 181 | 182 | import atexit 183 | atexit.register(self.stop) 184 | 185 | def set_input(self, input_device, block_duration = 250, api = 'MME'): 186 | self._input_id, self._input_device = self.get_device(input_device, 'input', api) 187 | self.samplerate = self._samplerate 188 | self.block_size = int(self._samplerate * self._block_length / 1000) 189 | assert(self.block_size == 4000) 190 | if self.stream: 191 | self.start() 192 | 193 | @classmethod 194 | def validate(cls, model, inputs: torch.Tensor): 195 | with torch.no_grad(): 196 | outs = model(inputs) 197 | return outs 198 | 199 | def activation_function(self, data): 200 | chunks = torch.Tensor(data[:,1]) 201 | out = self.validate(model, chunks) 202 | 203 | if out[0][1] > 0.5: 204 | self.active_counter = self._active_count 205 | return True 206 | elif self.active_counter > 0: 207 | self.active_counter -= 1 208 | return True 209 | else: 210 | return False 211 | 212 | def callback(self, indata, outdata, frames, time, status): 213 | if self.activation_function(indata): 214 | outdata[:] = indata 215 | else: 216 | outdata[:] = 0 217 | 218 | if __name__ == "__main__": 219 | import argparse 220 | from settings import settings 221 | def parse_arguments(): 222 | def int_or_str(text): 223 | """Helper function for argument parsing.""" 224 | try: 225 | return int(text) 226 | except ValueError: 227 | return text 228 | 229 | parser = argparse.ArgumentParser(description=__doc__) 230 | parser.add_argument('-i', '--input-device', type=int_or_str, default = settings.get("input-device", 'microphone'), help='input device ID or substring') 231 | parser.add_argument('-o', '--output-device', type=int_or_str, default = settings.get("output-device", 'CABLE Input'), help='output device ID or substring') 232 | parser.add_argument('-b', '--block-duration', type=float, metavar='DURATION', default = settings.get("block-duration", 50), help='block size (default %(default)s milliseconds)') 233 | parser.add_argument('-a','--active-level', type=float, default = float(settings.get("active-level", 3)), help = "audio level required to activate your microphone within the activation frequency band") 234 | parser.add_argument('-c','--active-count', type=int, default = settings.get("active-count", 10), help = "number of blocks to continue recording after activation") 235 | parser.add_argument('-s','--start', type=int, default = settings.get("start", 0), help = "activation filter starting frequency band") 236 | parser.add_argument('-e','--end', type=int, default = settings.get("end", 20), help = "activation filter ending frequncy band") 237 | args = parser.parse_args() 238 | print("Arguments loaded:") 239 | print(json.dumps(vars(args), indent = 4)) 240 | return parser 241 | 242 | parser = parse_arguments() 243 | args = parser.parse_args() 244 | 245 | #sf = SoundFilter(args.input_device, args.output_device, args.active_level, args.active_count, args.start, args.end, args.block_duration).start() 246 | sf = SileroSoundFilter(args.input_device, args.output_device).start() 247 | print('#' * 80) 248 | print('press Return to quit') 249 | print('#' * 80) 250 | input() -------------------------------------------------------------------------------- /spectrum_analyzer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Plot the live microphone signal(s) RFFT with matplotlib. 3 | 4 | Matplotlib and NumPy have to be installed. 5 | 6 | """ 7 | import argparse 8 | import queue 9 | import sys 10 | import math 11 | import json 12 | 13 | 14 | def int_or_str(text): 15 | """Helper function for argument parsing.""" 16 | try: 17 | return int(text) 18 | except ValueError: 19 | return text 20 | 21 | defaults = {} 22 | try: 23 | with open("config.json") as config: 24 | configs = json.load(config) 25 | defaults.update(configs) 26 | except: 27 | print("Could not load configuration from config.json, using defaults for command line parameters...") 28 | 29 | parser = argparse.ArgumentParser(description=__doc__) 30 | parser.add_argument('-l', '--list-devices', action='store_true', help='show list of audio devices and exit') 31 | parser.add_argument('-d', '--device', type=int_or_str, default = defaults.get("input-device", None), help='input device (numeric ID or substring)') 32 | parser.add_argument('-b', '--block-duration', type=float, metavar='DURATION', default=50, help='block size (default %(default)s milliseconds)') 33 | parser.add_argument('-c', '--channel', type=int, default=0, help='channel used for spectrum analysis (default 0)') 34 | parser.add_argument('-i', '--interval', type=float, default=30, help='minimum time between plot updates (default: %(default)s ms)') 35 | parser.add_argument('--log', action='store_true', help='Stores audio data after fft into data.csv') 36 | args = parser.parse_args() 37 | 38 | if args.channel < 0: 39 | parser.error('Channel must be greater than 0') 40 | 41 | q = queue.Queue() 42 | datafile = None 43 | plot_data = None 44 | alpha = 0.25 45 | def audio_callback(indata, frames, time, status): 46 | """This is called (from a separate thread) for each audio block.""" 47 | if status: 48 | print(status) 49 | if not q.full(): 50 | q.put(indata[:, args.channel]) 51 | 52 | def update_plot(frame): 53 | """This is called by matplotlib for each plot update. 54 | 55 | Typically, audio callbacks happen more frequently than plot updates, 56 | therefore the queue tends to contain multiple blocks of audio data. 57 | 58 | """ 59 | global plot_data 60 | try: 61 | data = q.get() 62 | data = np.fft.rfft(data) 63 | data = np.abs(data) 64 | plot_data = alpha*data+(1-alpha)*plot_data 65 | 66 | for line in lines: 67 | line.set_ydata(plot_data/np.ptp(plot_data)) 68 | 69 | if datafile: 70 | np.savetxt(datafile, data[None,:], delimiter=',', fmt='%.4f') 71 | 72 | return lines 73 | except queue.Empty: 74 | return lines 75 | 76 | try: 77 | from matplotlib.animation import FuncAnimation 78 | import matplotlib.pyplot as plt 79 | import numpy as np 80 | import sounddevice as sd 81 | 82 | if args.list_devices: 83 | print(sd.query_devices()) 84 | parser.exit(0) 85 | 86 | # Do information calculation and output information for user 87 | device_info = sd.query_devices(args.device, 'input') 88 | print('Using device:', args.device, device_info) 89 | samplerate = device_info['default_samplerate'] 90 | block_size = math.ceil(samplerate * args.block_duration / 1000) 91 | freq = np.fft.rfftfreq(block_size, d=1./samplerate) 92 | fftlen = len(freq) 93 | print('Plotting with:', fftlen, 'points of data corresponding to a range of:', freq[1], 'Hz per point of data.') 94 | 95 | if args.log: 96 | try: 97 | datafile = open('data.csv','ab') 98 | np.savetxt(datafile, freq[None,:], delimiter=',', fmt='%.4f') 99 | except: 100 | datafile = None 101 | 102 | plot_data = np.zeros(fftlen) 103 | 104 | fig, ax = plt.subplots() 105 | lines = ax.plot(plot_data) 106 | ax.axis((0, len(plot_data), 0, 1)) 107 | ax.set_yticks([0]) 108 | ax.yaxis.grid(True) 109 | ax.tick_params(bottom=False, top=False, labelbottom=False, right=False, left=False, labelleft=False) 110 | fig.tight_layout(pad=0) 111 | 112 | stream = sd.InputStream(device=args.device, blocksize=int(samplerate * args.block_duration / 1000), 113 | samplerate=samplerate, callback=audio_callback) 114 | 115 | ani = FuncAnimation(fig, update_plot, interval=args.interval, blit=True) 116 | with stream: 117 | plt.show() 118 | 119 | except Exception as e: 120 | parser.exit(type(e).__name__ + ': ' + str(e)) 121 | finally: 122 | if datafile: 123 | datafile.close() --------------------------------------------------------------------------------