├── .coveragerc ├── .github └── workflows │ └── pya-ci.yaml ├── .gitignore ├── Changelog.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── appveyor.yml ├── binder ├── environment.yml └── postBuild ├── ci └── test-environment.yml ├── dev ├── dev.md └── generate_doc ├── docs ├── _templates │ └── layout.html ├── conf.py └── index.rst ├── examples ├── pya-example-Amfcc.ipynb ├── pya-examples.ipynb └── samples │ ├── notes_sr32000_stereo.aif │ ├── ping.mp3 │ ├── sentence.wav │ ├── snap.wav │ ├── sonification.wav │ ├── stereoTest.wav │ └── vocal_sequence.wav ├── pya ├── __init__.py ├── amfcc.py ├── arecorder.py ├── aserver.py ├── asig.py ├── aspec.py ├── astft.py ├── backend │ ├── Dummy.py │ ├── Jupyter.py │ ├── PyAudio.py │ ├── __init__.py │ └── base.py ├── helper │ ├── __init__.py │ ├── backend.py │ ├── codec.py │ ├── helpers.py │ └── visualization.py ├── ugen.py └── version.py ├── requirements.txt ├── requirements_doc.txt ├── requirements_pyaudio.txt ├── requirements_remote.txt ├── requirements_test.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── helpers.py ├── test_amfcc.py ├── test_arecorder.py ├── test_aserver.py ├── test_asig.py ├── test_aspec.py ├── test_astft.py ├── test_backend.py ├── test_class_transformation.py ├── test_codestyle.py ├── test_file_loader.py ├── test_find_events.py ├── test_getitem.py ├── test_helpers.py ├── test_play.py ├── test_routeNpan.py ├── test_setitem.py ├── test_ugen.py └── test_visualization.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = pya 3 | 4 | [report] 5 | exclude_lines = 6 | # Have to re-enable the standard pragma 7 | pragma: no cover 8 | 9 | # Don't complain if tests don't hit defensive assertion code: 10 | raise NotImplementedError 11 | -------------------------------------------------------------------------------- /.github/workflows/pya-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Pya 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | branches: 10 | - master 11 | - develop 12 | 13 | jobs: 14 | unit-test: 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 120 17 | strategy: 18 | matrix: 19 | os: ["ubuntu-latest", "macos-latest"] 20 | python-version: ["3.8", "3.9", "3.10"] 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Install portaudio Ubuntu 24 | if: matrix.os == 'ubuntu-latest' 25 | shell: bash -l {0} 26 | run: sudo apt-get install portaudio19-dev 27 | - name: Install portaudio MacOS 28 | if: matrix.os == 'macos-latest' 29 | shell: bash -l {0} 30 | run: brew install portaudio 31 | - uses: conda-incubator/setup-miniconda@v2 32 | with: 33 | activate-environment: test-env 34 | environment-file: ci/test-environment.yml 35 | python-version: ${{ matrix.python-version }} 36 | auto-activate-base: false 37 | - name: Set up depen 38 | shell: bash -l {0} 39 | run: | 40 | conda init bash 41 | conda activate test-env 42 | conda install ffmpeg coverage python-coveralls --file=requirements_remote.txt --file=requirements_test.txt 43 | # pyaudio is not yet available on conda 44 | pip install -r requirements.txt 45 | - name: Run tests 46 | shell: bash -l {0} 47 | run: | 48 | conda activate test-env 49 | pytest --cov pya/ 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # DS_Store 7 | .DS_Store 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # pyenv 79 | .python-version 80 | 81 | # celery beat schedule file 82 | celerybeat-schedule 83 | 84 | # SageMath parsed files 85 | *.sage.py 86 | 87 | # Environments 88 | .env 89 | .venv 90 | env/ 91 | venv/ 92 | ENV/ 93 | env.bak/ 94 | venv.bak/ 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # mkdocs documentation 104 | /site 105 | 106 | # mypy 107 | .mypy_cache/ 108 | 109 | .vscode 110 | .idea 111 | 112 | # unittest and nose 113 | .noseids 114 | 115 | .vim 116 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.2 (Nov 2023) 4 | * #82, `pyaudio` is now optional: If you plan to use `PyAudioBackend`, install `pya` with `pip install pya[pyaudio]`. 5 | * Fix audio device bug 6 | * #77, Improve code type hint 7 | * #79, Use pyamapping 8 | 9 | ## 0.5.1 (Dec 2022) 10 | * Now support Python3.10 11 | * Bugfix #67: When the channels argument of Aserver and Arecorder has not been set it was determined by the default device instead of the actual device. 12 | 13 | ## 0.5.0 (Oct 2022) 14 | * Make Aserver a context that can wrap play inside. This context will handle boot() and quit() automatically. 15 | * Move OSX/Linux CI to Github Action from TravisCI. 16 | * Bump numpy dependency, numpy.linspace usage is updated. 17 | * Make Asig.play() arguments explicit. 18 | * Asig.dur property that returns the duration in seconds of the signal 19 | 20 | ## 0.4.3 (March 2021) 21 | * Add logging helper function. 22 | * Make sure `pya` runs on Python3.8 23 | * Bugfix #43: Handle `None` as a return value of `get_server_info` in `pya.helper.backend.determine_backend` (thanks @paulvickers) 24 | 25 | ## 0.4.1 (July 2020) 26 | * Fix bug of extend mode result in a change of dtype (from the default f32 to f64). Now it is fixed at f32. 27 | * Add convolve() method. A convolution method (default to fft). Now you can apply reverb, do autocorrelation to the signal. 28 | * Update Ugen docstring and remove deprecated variable names 29 | * namespace improvement. 30 | * `sanic` and `notebook` are now optional: If you plan to use `JupyterBackend`, install `pya` with `pip install pya[remote]` 31 | 32 | ## 0.4.0 (April 2020) 33 | * new Amfcc class for MFCC feature extraction 34 | * new helper.visualization.py and gridplot() 35 | * optimise plot() methods of all classes for better consistency in both style and the API 36 | * new helper functions: padding, is_pow2, next_pow2, round_half_up, rolling_window, signal_to_frame, 37 | magspec, powspec, hz2mel, mel2hz_ 38 | * Fixed bug that for multichanels spectrum the rfft is not performed on the x-axis 39 | 40 | ## 0.3.3 (February 2020) 41 | 42 | * Bugfix #34: Improve indexing of Asig 43 | * Added developer tools for documentation generation 44 | * Improved string handling to prevent Python 3.8 warnings 45 | * Fixed reverse proxy resolution in JupyterBackend for Binder hosts other than Binderhub 46 | * Fixed travis job configuration 47 | 48 | ## 0.3.2 (November 2019) 49 | 50 | * Fixed bug of multi channel fade_in fade_out 51 | * Introduced timeslice in extend setitem mode as long as stop reaches the end of the array. 52 | * Added Sphinx documentation which can be found at https://interactive-sonification.github.io/pya 53 | * Introduced new Arecorder method to individualize channel selection and gain adjustment to each channel 54 | * Added Binder links to Readme 55 | * Introduced Jupyter backend based on WebAudio 56 | * Updated example notebook to use WebAudio when used with Binder 57 | * Switched test framework from nosetests to pytest 58 | 59 | 60 | ## 0.3.1 (October 2019) 61 | 62 | * Remove ffmpeg from requirement, and it has to be installed via conda or manually 63 | * Decouple pyaudio from Aserver and Arecorder 64 | * Introduce backends interface: PyAudio(), Dummy() 65 | * add device_info() helper function which prints audio device information in a tabular form 66 | * Bugfix #23: Add a small delay to server booting to prevent issues when Aserver and Arecorder are initialized back-to-back 67 | * Helper function `record` has been dropped due to legacy reasons. An improved version will be introduced in 0.3.2. 68 | 69 | 70 | ## 0.3.0 (October 2019) 71 | 72 | * Restructure Asig, Astft, Aspec into different files 73 | * Add Arecorder class 74 | * Several bug fixes 75 | * Made ffmpeg optional 76 | * Introduced CI testing with travis (*nix) and appveyor (Windows) 77 | * Introduced coveralls test coverage analysis 78 | 79 | 80 | ## 0.2.1 (August 2019) 81 | 82 | * 0.2.1 is a minor update that removes the audioread dependency and directly opt for standard library for .wav and .aif support, ffmpeg for .mp3 support. 83 | * Bugfix for multichannels audio file loading resulting transposed columns and rows. 84 | 85 | 86 | ## 0.2 (August 2019) 87 | 88 | * First official PyPI release 89 | 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Thomas Hermann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include *.txt 3 | include *.yml 4 | include .coveragerc 5 | include .pylintrc 6 | include LICENSE 7 | include MANIFEST 8 | include tox.ini 9 | include binder/environment.yml 10 | include binder/postBuild 11 | include dev/generate_doc 12 | 13 | recursive-include docs Makefile 14 | recursive-include docs *.bat 15 | recursive-include docs *.html 16 | recursive-include docs *.py 17 | recursive-include docs *.rst 18 | 19 | recursive-include examples *.aif 20 | recursive-include examples *.ipynb 21 | recursive-include examples *.mp3 22 | recursive-include examples *.py 23 | recursive-include examples *.wav 24 | recursive-exclude examples .ipynb_checkpoints/*.ipynb 25 | 26 | recursive-include tests *.py 27 | recursive-include pya *.py 28 | recursive-include dev *.md 29 | recursive-include dev *.py 30 | recursive-include ci *.yml 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/pya.svg)](https://pypi.org/project/pya) 2 | [![License](https://img.shields.io/github/license/interactive-sonification/pya.svg)](LICENSE) 3 | 4 | # pya 5 | 6 | |Branch|`master`|`develop`| 7 | |------:|--------:|---------:| 8 | |[CI-Linux/MacOS](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml) | [![Build Status Master](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml/badge.svg?branch=master)](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml?query=branch%3Amaster) | [![Build Status Develop](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml/badge.svg?branch=develop)](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml?query=branch%3Adevelop) | 9 | |[CI-Windows](https://ci.appveyor.com/project/aleneum/pya-b7gkx/)| ![Build status AppVeyor](https://ci.appveyor.com/api/projects/status/vn61qeri0uyxeedv/branch/master?svg=true) | ![Build status AppVeyor](https://ci.appveyor.com/api/projects/status/vn61qeri0uyxeedv/branch/develop?svg=true) | 10 | |Changes|[![GitHub commits](https://img.shields.io/github/commits-since/interactive-sonification/pya/v0.5.0/master.svg)](https://github.com/interactive-sonification/pya/compare/v0.5.0...master) | [![GitHub commits](https://img.shields.io/github/commits-since/interactive-sonification/pya/v0.5.0/develop.svg)](https://github.com/interactive-sonification/pya/compare/v0.5.0...develop) | 11 | |Binder|[![Master Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/interactive-sonification/pya/master?filepath=examples%2Fpya-examples.ipynb) | [![Develop Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/interactive-sonification/pya/develop?filepath=examples%2Fpya-examples.ipynb) | 12 | 13 | ## What is pya? 14 | 15 | pya is a package to support creation and manipulation of audio signals with Python. 16 | It uses numpy arrays to store and compute audio signals. 17 | 18 | * Documentation: see examples/pya-examples.ipynb for a quick tutorial and [Documentation](https://interactive-sonification.github.io/pya/index.html) 19 | * Source code: https://github.com/interactive-sonification/pya 20 | 21 | It provides: 22 | 23 | * Asig - a versatile audio signal class 24 | * Ugen - a subclass of Asig, which offers unit generators 25 | such as sine, square, sawtooth, noise 26 | * Aserver - an audio server class for queuing and playing Asigs 27 | * Arecorder - an audio recorder class 28 | * Aspec - an audio spectrum class, using rfft as real-valued signals are always implied 29 | * Astft - an audio STFT (short-term Fourier transform) class 30 | * A number of helper functions, e.g. device_info() 31 | 32 | pya can be used for 33 | * multi-channel audio processing 34 | * auditory display and sonification 35 | * sound synthesis experiment 36 | * audio applications in general such as games or GUI-enhancements 37 | * signal analysis and plotting 38 | 39 | At this time pya is more suitable for offline rendering than realtime. 40 | 41 | ## Authors and Contributors 42 | 43 | * [Thomas](https://github.com/thomas-hermann) (author, maintainer) 44 | * [Jiajun](https://github.com/wiccy46) (co-author, maintainer) 45 | * [Alexander](https://github.com/aleneum) (maintainer) 46 | * Contributors will be acknowledged here, contributions are welcome. 47 | 48 | ## Installation 49 | 50 | Install using 51 | ``` 52 | pip install pya 53 | ``` 54 | 55 | However to play and record audio you need a backend. 56 | 57 | - `pip install pya[remote]` for a web based Jupyter backend 58 | - `pip install pya[pyaudio]` for `portaudio` and its Python wrapper `PyAudio` 59 | 60 | ### Using Conda 61 | 62 | Pyaudio can be installed via [conda](https://docs.conda.io): 63 | 64 | ``` 65 | conda install pyaudio 66 | ``` 67 | 68 | Disclaimer: Python 3.10+ requires PyAudio 0.2.12 which is not available on Conda as of December 2022. [Conda-forge](https://conda-forge.org/) provides a version only for Linux at the moment. Users of Python 3.10 should for now use other installation options. 69 | 70 | ### Using Homebrew and PIP (MacOS only) 71 | 72 | 73 | ``` 74 | brew install portaudio 75 | ``` 76 | 77 | Then 78 | 79 | ``` 80 | pip install pya 81 | ``` 82 | 83 | For Apple ARM Chip, if you failed to install the PyAudio dependency, you can follow this guide: [Installation on ARM chip](https://stackoverflow.com/a/73166852/4930109) 84 | - Option 1: Create .pydistutils.cfg in your home directory, `~/.pydistutils.cfg`, add: 85 | 86 | ``` 87 | echo "[build_ext] 88 | include_dirs=$(brew --prefix portaudio)/include/ 89 | library_dirs=$(brew --prefix portaudio)/lib/" > ~/.pydistutils.cfg 90 | ``` 91 | Use pip: 92 | 93 | ``` 94 | pip install pya 95 | ``` 96 | 97 | You can remove the `.pydistutils.cfg` file after installation. 98 | 99 | - Option 2: Use `CFLAGS`: 100 | 101 | ``` 102 | CFLAGS="-I/opt/homebrew/include -L/opt/homebrew/lib" pip install pya 103 | ``` 104 | 105 | 106 | 107 | ### Using PIP (Linux) 108 | 109 | Try `sudo apt-get install portaudio19-dev` or equivalent to your distro, then 110 | 111 | ``` 112 | pip install pya 113 | ``` 114 | 115 | ### Using PIP (Windows) 116 | 117 | [PyPI](https://pypi.org/) provides [PyAudio wheels](https://pypi.org/project/PyAudio/#files) for Windows including portaudio: 118 | 119 | ``` 120 | pip install pyaudio 121 | ``` 122 | 123 | should be sufficient. 124 | 125 | 126 | ## A simple example 127 | 128 | ### Startup: 129 | 130 | ```Python 131 | import pya 132 | s = pya.Aserver(bs=1024) 133 | pya.Aserver.default = s # to set as default server 134 | s.boot() 135 | ``` 136 | 137 | ### Create an Asig signal: 138 | 139 | A 1s / 440 Hz sine tone at sampling rate 44100 as channel name 'left': 140 | 141 | ```Python 142 | import numpy as np 143 | signal_array = np.sin(2 * np.pi * 440 * np.linspace(0, 1, 44100)) 144 | atone = pya.Asig(signal_array, sr=44100, label='1s sine tone', cn=['left']) 145 | ``` 146 | 147 | Other ways of creating an Asig object: 148 | 149 | ```Python 150 | asig_int = pya.Asig(44100, sr=44100) # zero array with 44100 samples 151 | asig_float = pya.Asig(2., sr=44100) # float argument, 2 seconds of zero array 152 | asig_str = pya.Asig('./song.wav') # load audio file 153 | asig_ugen = pya.Ugen().square(freq=440, sr=44100, dur=2., amp=0.5) # using Ugen class to create common waveforms 154 | ``` 155 | 156 | Audio files are also possible using the file path. `WAV` should work without issues. `MP3` is supported but may raise error if [FFmpeg](https://ffmpeg.org/). 157 | 158 | If you use Anaconda, installation is quite easy: 159 | 160 | `conda install -c conda-forge ffmpeg` 161 | 162 | Otherwise: 163 | 164 | * Mac or Linux with brew 165 | - `brew install ffmpeg` 166 | * On Linux 167 | - Install FFmpeg via apt-get: `sudo apt install ffmpeg` 168 | * On Windows 169 | - Download the latest distribution from https://ffmpeg.zeranoe.com/builds/ 170 | - Unzip the folder, preferably to `C:\` 171 | - Append the FFmpeg binary folder (e.g. `C:\ffmpeg\bin`) to the PATH system variable ([How do I set or change the PATH system variable?](https://www.java.com/en/download/help/path.xml)) 172 | ### Key attributes 173 | * `atone.sig` --> The numpy array containing the signal is 174 | * `atone.sr` --> the sampling rate 175 | * `atone.cn` --> the list of custom defined channel names 176 | * `atone.label` --> a custom set identifier string 177 | 178 | ### Play signals 179 | 180 | atone.play(server=s) 181 | 182 | play() uses Aserver.default if server is not specified 183 | 184 | Instead of specifying a long standing server. You can also use `Aserver` as a context: 185 | 186 | ```Python 187 | with pya.Aserver(sr=48000, bs=256, channels=2) as aserver: 188 | atone.play(server=aserver) # Or do: aserver.play(atone) 189 | ``` 190 | 191 | The benefit of this is that it will handle server bootup and shutdown for you. But notice that server up/down introduces extra latency. 192 | 193 | ### Play signal on a specific device 194 | 195 | ```Python 196 | from pya import find_device 197 | from pya import Aserver 198 | devices = find_device() # This will return a dictionary of all devices, with their index, name, channels. 199 | s = Aserver(sr=48000, bs=256, device=devices['name_of_your_device']['index']) 200 | ``` 201 | 202 | 203 | ### Plotting signals 204 | 205 | to plot the first 1000 samples: 206 | 207 | atone[:1000].plot() 208 | 209 | to plot the magnitude and phase spectrum: 210 | 211 | atone.plot_spectrum() 212 | 213 | to plot the spectrum via the Aspec class 214 | 215 | atone.to_spec().plot() 216 | 217 | to plot the spectrogram via the Astft class 218 | 219 | atone.to_stft().plot(ampdb) 220 | 221 | ### Selection of subsets 222 | * Asigs support multi-channel audio (as columns of the signal array) 223 | * `a1[:100, :3]` would select the first 100 samples and the first 3 channels, 224 | * `a1[{1.2:2}, ['left']]` would select the channel named 'left' using a time slice from 1 225 | 226 | ### Recording from Device 227 | 228 | `Arecorder` allows recording from input device 229 | 230 | ```Python 231 | import time 232 | 233 | from pya import find_device 234 | from pya import Arecorder 235 | devices = find_device() # Find the index of the input device 236 | arecorder = Arecorder(device=some_index, sr=48000, bs=512) # Or not set device to let pya find the default device 237 | arecorder.boot() 238 | arecorder.record() 239 | time.sleep(2) # Recording is non-blocking 240 | arecorder.stop() 241 | last_recording = arecorder.recordings[-1] # Each time a recorder stop, a new recording is appended to recordings 242 | ``` 243 | 244 | ### Method chaining 245 | Asig methods usually return an Asig, so methods can be chained, e.g 246 | 247 | atone[{0:1.5}].fade_in(0.1).fade_out(0.8).gain(db=-6).plot(lw=0.1).play(rate=0.4, onset=1) 248 | 249 | ### Learning more 250 | * Please check the examples/pya-examples.ipynb for more examples and details. 251 | 252 | 253 | ## Contributing 254 | * Please get in touch with us if you wish to contribute. We are happy to be involved in the discussion of new features and to receive pull requests. 255 | 256 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # no dedicated build phase needed so far 2 | build: false 3 | version: "0.3.2-{build}" 4 | 5 | branches: 6 | only: 7 | - master 8 | - develop 9 | - dev-appveyor 10 | 11 | environment: 12 | matrix: 13 | - PYTHON_VERSION: 3.10 14 | MINICONDA: C:\Miniconda3-x64 15 | 16 | init: 17 | - "ECHO %PYTHON_VERSION% %MINICONDA%" 18 | 19 | install: 20 | - "set PATH=%MINICONDA%;%MINICONDA%\\Scripts;%MINICONDA%\\Library\\bin;%PATH%" 21 | - conda config --set always_yes yes --set changeps1 no 22 | - conda update -q conda 23 | - conda info -a 24 | - conda config --append channels conda-forge 25 | - "conda create -q -n test-environment python=%PYTHON_VERSION% ffmpeg coverage --file=requirements_remote.txt --file=requirements_test.txt" 26 | - activate test-environment 27 | - "pip install -r requirements.txt -r requirements_pyaudio.txt" 28 | 29 | test_script: 30 | - pytest 31 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: pya 2 | dependencies: 3 | - numpy 4 | - scipy 5 | - matplotlib 6 | - pyaudio>=0.2.11 7 | - ffmpeg 8 | - ipywidgets 9 | - requests 10 | - notebook 11 | - websockets 12 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export JUPYTER_SERVER_URL 4 | pip install jupyter_server_proxy 5 | jupyter serverextension enable --py jupyter_server_proxy --sys-prefix 6 | -------------------------------------------------------------------------------- /ci/test-environment.yml: -------------------------------------------------------------------------------- 1 | name: test-env 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - anaconda-client 6 | - pip 7 | -------------------------------------------------------------------------------- /dev/dev.md: -------------------------------------------------------------------------------- 1 | # Release Cycle 2 | 3 | ## Process 4 | 5 | * Update your build tools 6 | ``` 7 | pip3 install -U setuptools twine wheel 8 | ``` 9 | * Check/adjust version in `pya/versions.py` 10 | * Update Changelog.md 11 | * Run tox 12 | ``` 13 | tox 14 | ``` 15 | * Remove old releases 16 | ``` 17 | rm dist/* 18 | ``` 19 | * Build releases 20 | ``` 21 | python3 setup.py sdist bdist_wheel 22 | ``` 23 | * Publish to test.pypi 24 | ``` 25 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 26 | ``` 27 | * [Check](https://test.pypi.org/project/pya/) the test project page 28 | 29 | * Test install uploaded release 30 | ``` 31 | pip3 install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple pya 32 | ``` 33 | * Upload the official release 34 | ``` 35 | twine upload dist/* 36 | ``` 37 | * [Check](https://pypi.org/project/pya/) the public project page 38 | 39 | * Test install uploaded release 40 | ``` 41 | pip3 install pya 42 | ``` 43 | * Draft a release on [Github](https://github.com/thomas-hermann/pya/releases) 44 | 45 | ## Updating documentation 46 | 47 | * Install requirements required to generate the sphinx documenation 48 | 49 | ``` 50 | pip3 install -r dev/requirements_doc.txt 51 | ``` 52 | 53 | * Generate local documentation 54 | ``` 55 | # this will attempt to open a browser after generation 56 | python3 dev/generate_doc 57 | ``` 58 | 59 | * Generate and publish documentation for all tags and the branches master and develop 60 | ``` 61 | # Paramater remarks: 62 | # --doctree - generate complete documentation. Ignore local files and checkout complete repo into build 63 | # --publish - publish generated docs to gh-pages instantly. This implicates --doctree 64 | # --clean - remove previous checkouts from build 65 | # --no-show - do not open a browser after generation 66 | 67 | python3 dev/generate_doc --doctree --publish --clean --no-show 68 | ``` 69 | 70 | ## Further Readings 71 | 72 | * [using test.PyPI](https://packaging.python.org/guides/using-testpypi/) 73 | * [packaging](https://packaging.python.org/tutorials/packaging-projects/) 74 | -------------------------------------------------------------------------------- /dev/generate_doc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import sys 5 | import os 6 | import subprocess 7 | import shutil 8 | from sys import platform 9 | from os.path import exists, dirname, basename 10 | 11 | SILENCE = "> /dev/null 2>&1" 12 | 13 | # run pip install -r requirements_doc.txt to install dependencies 14 | # you usually want to run: 15 | # python dev/generate_doc --doctree --publish --clean --no-show 16 | 17 | def generate(): 18 | git_root = subprocess.check_output(f'git -C {dirname(__file__)} rev-parse --show-toplevel'.split(' ')) 19 | git_root = str(git_root, 'utf-8').strip() 20 | parser = argparse.ArgumentParser(description="Generates Sphinx documentation") 21 | parser.add_argument('--doctree', action='store_true', help='build complete doctree') 22 | parser.add_argument('--publish', action='store_true', help='build and publish doctree') 23 | parser.add_argument('--no-show', action='store_true', help='do not open browser after build') 24 | parser.add_argument('--branches', nargs='*', default=['master', 'develop'], help='limit doctree to these branches') 25 | parser.add_argument('--tags', nargs='*', default=False, help='limit doctree to these tags') 26 | parser.add_argument('--input', default=f"{git_root}/docs", help='input folder (ignored for doctree)') 27 | parser.add_argument('--out', default=f"{git_root}/build", help='output folder') 28 | parser.add_argument('--template-folder', default=f"{git_root}/docs/_templates", help="templates used for doctree") 29 | parser.add_argument('--clean', action='store_true', help="removes out folder before building doc") 30 | args = parser.parse_args() 31 | args.doctree = args.doctree if not args.publish else True 32 | if args.clean and exists(args.out): 33 | shutil.rmtree(args.out) 34 | os.makedirs(args.out, exist_ok=True) 35 | if not args.doctree: 36 | generate_local(doc=args.input, out=args.out, show=not args.no_show) 37 | else: 38 | generate_tree(out_folder=args.out, publish=args.publish, branches=args.branches, 39 | tags=args.tags, template_folder=args.template_folder, show=not args.no_show) 40 | 41 | 42 | def generate_local(doc, out, show, target='html'): 43 | os.system(f'sphinx-build -b {target} {doc} {out}/{target}') 44 | if show: 45 | url = f'{out}/{target}/index.{target}' 46 | if platform == 'darwin': 47 | os.system(f'open {url}') 48 | elif platform == 'win32': 49 | os.startfile(url) 50 | else: 51 | raise NotImplementedError("'show' is not available for your platform") 52 | 53 | 54 | def generate_tree(out_folder, publish=False, branches=['master', 'develop'], tags=None, template_folder=None, show=False): 55 | doctree_root = f"{out_folder}/doctree/pya" 56 | build_folder = f"{out_folder}/tmp" 57 | os.makedirs(build_folder, exist_ok=True) 58 | if not exists(doctree_root): 59 | os.system(f'git clone https://github.com/interactive-sonification/pya.git {doctree_root}') 60 | else: 61 | # os.system(f'git -C {doctree_root} fetch --all') 62 | os.system(f'git -C {doctree_root} pull --all') 63 | 64 | if tags is False: 65 | out = subprocess.check_output(f"git -C {doctree_root} show-ref --tags -d".split(' ')) 66 | tags = [str(line.split(b' ')[1].split(b'/')[2], 'utf-8') for line in out.split(b'\n')[::-1] if len(line)] 67 | 68 | # first, check which documentation to generate 69 | branches = [b for b in branches if _has_docs(b, doctree_root)] 70 | tags = [t for t in tags if _has_docs('tags/' + t, doctree_root)] 71 | print(f"Will generate documentation for:\nBranches: {branches}\nTags: {tags}") 72 | 73 | # fix order of dropdown elements (most recent tag first, then branches and older tags) 74 | doclist = tags[:1] + branches + tags[1:] 75 | 76 | # generate documentation; all versions have to be known for the dropdown menu 77 | for d in doclist: 78 | print(f"Generating documentation for {d} ...") 79 | target = d if d in branches else 'tags/' + d 80 | res = os.system(f'git -C {doctree_root} checkout {target} -f' + SILENCE) 81 | if res != 0: 82 | raise RuntimeError(f'Could not checkout {d}. Git returned status code {res}!') 83 | os.system(f'cp {template_folder}/../conf.py {doctree_root}/docs/') 84 | if template_folder: 85 | os.system(f'cp {template_folder}/* {doctree_root}/docs/_templates') 86 | 87 | # override index and config for versions older than 0.5.0 88 | if d[0] == 'v' and d[1] == '0' and int(d[3]) < 5: 89 | os.system(f'cp {template_folder}/../conf.py {template_folder}/../index.rst {doctree_root}/docs') 90 | 91 | # PYTHONDONTWRITEBYTECODE=1 prevents __pycache__ files which we don't need when code runs only once. 92 | call = f'PYTHONDONTWRITEBYTECODE=1 sphinx-build -b html -D version={d} -A versions={",".join(doclist)} {doctree_root}/docs {build_folder}/{d}' + SILENCE 93 | print(call) 94 | res = os.system(call) 95 | if res != 0: 96 | raise RuntimeError(f'Could not generate documentation for {d}. Sphinx returned status code {res}!') 97 | 98 | # create index html to forward to last tagged version 99 | if doclist: 100 | with open(f'{build_folder}/index.html', 'w') as fp: 101 | fp.write(f""" 102 | 103 | 104 | 105 | 106 | 107 | """) 108 | 109 | # prepare gh-pages 110 | print("Merging documentation...") 111 | os.system(f'git -C {doctree_root} checkout gh-pages -f' + SILENCE) 112 | os.system(f'cp -r {build_folder}/* {doctree_root}') 113 | print("Current differences in 'gh-branch':") 114 | os.system(f'git -C {doctree_root} status') 115 | print(f"Documentation tree has been written to {doctree_root}") 116 | 117 | # commit and push changes when publish has been passed 118 | if publish: 119 | os.system(f'git -C {doctree_root} add -A') 120 | os.system(f'git -C {doctree_root} commit -a -m "update doctree"') 121 | os.system(f'git -C {doctree_root} push') 122 | 123 | if show: 124 | _run_webserver(doctree_root) 125 | 126 | def _has_docs(name, doctree_root): 127 | os.system(f'git -C {doctree_root} checkout {name}' + SILENCE) 128 | return exists(f'{doctree_root}/docs') 129 | 130 | def _run_webserver(root): 131 | import threading 132 | import time 133 | import http.server 134 | import socketserver 135 | 136 | def _delayed_open(): 137 | url = f'http://localhost:8181/{basename(root)}/index.html' 138 | time.sleep(0.2) 139 | if sys.platform == 'darwin': 140 | os.system(f'open {url}') 141 | elif sys.platform == 'win32': 142 | os.startfile(url) 143 | else: 144 | raise NotImplementedError("'show' is not available for your platform") 145 | 146 | t = threading.Thread(target=_delayed_open) 147 | t.start() 148 | class Handler(http.server.SimpleHTTPRequestHandler): 149 | def __init__(self, *args, **kwargs): 150 | super().__init__(*args, directory=root + '/../', **kwargs) 151 | def log_message(self, format, *args): 152 | pass 153 | socketserver.TCPServer.allow_reuse_address = True 154 | with socketserver.TCPServer(("", 8181), Handler) as httpd: 155 | try: 156 | print("Starting preview webserver (disable with --no-show)...") 157 | print("Ctrl+C to kill server") 158 | httpd.serve_forever() 159 | except KeyboardInterrupt: 160 | print("Exiting...") 161 | httpd.shutdown() 162 | 163 | 164 | if __name__ == "__main__": 165 | generate() 166 | -------------------------------------------------------------------------------- /docs/_templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% block sidebartitle %} 3 | 4 | {% if logo and theme_logo_only %} 5 | 6 | {% else %} 7 | {{ project }} 8 | {% endif %} 9 | 10 | {% if logo %} 11 | {# Not strictly valid HTML, but it's the only way to display/scale 12 | it properly, without weird scripting or heaps of work 13 | #} 14 | 15 | {% endif %} 16 | 17 | 18 | {% if theme_display_version %} 19 | {%- set nav_version = version %} 20 | {% if READTHEDOCS and current_version %} 21 | {%- set nav_version = current_version %} 22 | {% endif %} 23 | {% if nav_version %} 24 | 25 |
26 | version: 31 |
32 | 33 | 34 | {% endif %} 35 | {% endif %} 36 | 37 | {% include "searchbox.html" %} 38 | 39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath('..')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'pya' 21 | copyright = '2020, Thomas Hermann, Jiajun Yang, Alexander Neumann' 22 | author = 'Thomas Hermann, Jiajun Yang, Alexander Neumann' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | version = 'unset' 26 | html_context = dict(versions=str(version)) 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.napoleon', 34 | 'sphinx_mdinclude', 35 | 'autoapi.extension'] 36 | 37 | source_suffix = ['.rst', '.md'] 38 | autoapi_type = 'python' 39 | autoapi_dirs = ['../pya'] 40 | autoapi_ignore = ['*/version.py'] 41 | autoapi_add_toctree_entry = False 42 | autosectionlabel_prefix_document = True 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ['_templates'] 46 | 47 | # List of patterns, relative to source directory, that match files and 48 | # directories to ignore when looking for source files. 49 | # This pattern also affects html_static_path and html_extra_path. 50 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '_templates'] 51 | 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = 'sphinx_rtd_theme' 59 | 60 | # Add any paths that contain custom static files (such as style sheets) here, 61 | # relative to this directory. They are copied after the builtin static files, 62 | # so a file named "default.css" will overwrite the builtin "default.css". 63 | html_static_path = [] 64 | 65 | 66 | def setup(app): 67 | config = { 68 | # 'url_resolver': lambda url: github_doc_root + url, 69 | 'auto_toc_tree_section': 'Contents', 70 | 'enable_eval_rst': True, 71 | } 72 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | 4 | .. mdinclude:: ../README.md 5 | 6 | Indices and tables 7 | ================== 8 | 9 | * :doc:`API Reference ` 10 | * :ref:`genindex` 11 | * :ref:`modindex` 12 | * :ref:`search` 13 | -------------------------------------------------------------------------------- /examples/samples/notes_sr32000_stereo.aif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/notes_sr32000_stereo.aif -------------------------------------------------------------------------------- /examples/samples/ping.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/ping.mp3 -------------------------------------------------------------------------------- /examples/samples/sentence.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/sentence.wav -------------------------------------------------------------------------------- /examples/samples/snap.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/snap.wav -------------------------------------------------------------------------------- /examples/samples/sonification.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/sonification.wav -------------------------------------------------------------------------------- /examples/samples/stereoTest.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/stereoTest.wav -------------------------------------------------------------------------------- /examples/samples/vocal_sequence.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/examples/samples/vocal_sequence.wav -------------------------------------------------------------------------------- /pya/__init__.py: -------------------------------------------------------------------------------- 1 | from .aserver import Aserver 2 | from .asig import Asig 3 | from .astft import Astft 4 | from .aspec import Aspec 5 | from .amfcc import Amfcc 6 | from .arecorder import Arecorder 7 | from .ugen import Ugen 8 | from .version import __version__ 9 | from .helper import * 10 | # from .helper.visualization import basicplots 11 | from .backend import * 12 | 13 | 14 | def startup(**kwargs): 15 | return Aserver.startup_default_server(**kwargs) 16 | 17 | 18 | def shutdown(**kwargs): 19 | Aserver.shutdown_default_server(**kwargs) 20 | -------------------------------------------------------------------------------- /pya/amfcc.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | from warnings import warn 3 | 4 | import numpy as np 5 | from pyamapping import mel_to_hz, hz_to_mel 6 | from scipy.signal import get_window 7 | from scipy.fftpack import dct 8 | 9 | from .helper import next_pow2, signal_to_frame, round_half_up, magspec 10 | from .helper import is_pow2 11 | from .helper import basicplot 12 | import pya.asig 13 | import logging 14 | 15 | # _LOGGER = logging.getLogger(__name__) 16 | # _LOGGER.addHandler(logging.NullHandler()) 17 | 18 | 19 | class Amfcc: 20 | """Mel filtered Fourier spectrum (MFCC) class, 21 | this class is inspired by jameslyons/python_speech_features, 22 | https://github.com/jameslyons/python_speech_features 23 | Steps of mfcc: 24 | * Frame the signal into short frames. 25 | * For each frame calculate the periodogram estimate of the 26 | power spectrum. 27 | * Apply the mel filterbank to the power spectra, sum the energy 28 | in each filter. 29 | * Take the DCT of the log filterbank energies. 30 | * Keep DCT coefficients 2-13, discard the rest. 31 | * Take the logarithm of all filterbank energies. 32 | 33 | Attributes 34 | ---------- 35 | x : Asig or numpy.ndarray 36 | x can be two forms, the most commonly used is an Asig object. 37 | Such as directly acquired from an Asig object via Asig.to_stft(). 38 | sr : int 39 | sampling rate, this is only necessary if x is not Asig. 40 | duration : float 41 | Duration of the signal in second, 42 | label : str 43 | A string label as an identifier. 44 | n_per_frame : int 45 | Number of samples per frame 46 | hopsize : int 47 | Number of samples of each successive frame. 48 | nfft : int 49 | FFT size, default to be next power of 2 integer of n_per_frame 50 | window : str 51 | Type of the window function (Default value='hann'), 52 | use scipy.signal.get_window to return a numpy array. 53 | If None, no windowing will be applied. 54 | nfilters : int 55 | The number of mel filters. Default is 26 56 | ncep : int 57 | Number of cepstrum. Default is 13 58 | cepliter : int 59 | Lifter's cepstral coefficient. Default is 22 60 | frames : numpy.ndarray 61 | The original signal being reshape into frame based on 62 | n_per_frame and hopsize. 63 | frame_energy : numpy.ndarray 64 | Total power spectrum energy of each frame. 65 | filter_banks : numpy.ndarray 66 | An array of mel filters 67 | cepstra : numpy.ndarray 68 | An array of the MFCC coeffcient, size: nframes x ncep 69 | """ 70 | 71 | def __init__(self, x: Union[pya.Asig, np.ndarray], sr: Optional[int] = None, 72 | label: str = '', n_per_frame: Optional[int] = None, 73 | hopsize: Optional[int] = None, nfft: Optional[int] = None, 74 | window: str = 'hann', nfilters: int = 26, 75 | ncep: int = 13, ceplifter: int = 22, preemph: float = 0.95, 76 | append_energy: bool = True, cn: Optional[list] = None): 77 | """Initialize Amfcc object 78 | 79 | Parameters 80 | ---------- 81 | x : Asig or numpy.ndarray 82 | x can be two forms, the most commonly used is an Asig object. 83 | Such as directly acquired from an Asig object via Asig.to_stft(). 84 | sr : int, optional 85 | Sampling rate, this is not necessary if x is an Asig object as 86 | it has sr already. 87 | label : str, optional 88 | Identifier for the object 89 | n_per_frame : int, optional 90 | Number of samples per frame. Default is the equivalent of 91 | 25ms based on the sr. 92 | hopsize : int, optional 93 | Number of samples of each successive frame, Default is the 94 | sample equivalent of 10ms. 95 | nfft : int, optional 96 | FFT size, default to be next power of 2 integer of n_per_frame 97 | window : str, optional 98 | Type of the window function (Default value='hann'), 99 | use scipy.signal.get_window to 100 | return a numpy array. If None, no windowing will be applied. 101 | nfilters : int 102 | The number of mel filters. Default is 26 103 | ncep : int 104 | Number of cepstrum. Default is 13 105 | ceplifter : int 106 | Lifter's cepstral coefficient. Default is 22 107 | preemph : float 108 | Preemphasis coefficient. Default is 0.95 109 | append_energy : bool 110 | If true, the zeroth cepstral coefficient is replaced with the log 111 | of the total frame energy. 112 | cn : list or None 113 | A list of channel names, size should match the channels. 114 | """ 115 | # ----------Prepare attributes ------------`------------- 116 | # First prepare for parameters 117 | # x represent the audio signal, which can be Asig object or np.array. 118 | self.im = None 119 | if isinstance(x, pya.asig.Asig): 120 | self.sr = x.sr 121 | self.x = x.sig 122 | self.label = ''.join([x.label, "_mfccs"]) 123 | self.duration = x.get_duration() 124 | self.channels = x.channels 125 | self.cn = x.cn 126 | 127 | elif isinstance(x, np.ndarray): 128 | self.x = x 129 | if sr: 130 | self.sr = sr 131 | else: 132 | msg = "If x is an array," \ 133 | " sra(sampling rate) needs to be defined." 134 | raise AttributeError(msg) 135 | self.duration = np.shape(x)[0] / self.sr 136 | self.label = label 137 | self.channels = 1 if self.x.ndim == 1 else self.x.shape[1] 138 | self.cn = cn 139 | else: 140 | msg = "x can only be either a numpy.ndarray or Asig object." 141 | raise TypeError(msg) 142 | 143 | # default 25ms length window. 144 | self.n_per_frame = n_per_frame or int(round_half_up(self.sr * 0.025)) 145 | # default 10ms overlap 146 | self.hopsize = hopsize or int(round_half_up(self.sr * 0.01)) 147 | if self.hopsize > self.n_per_frame: 148 | msg = "noverlap > nperseg, this leaves gaps between frames." 149 | warn(msg) 150 | self.nfft = nfft or next_pow2(self.n_per_frame) 151 | if not is_pow2(self.nfft): 152 | msg = "nfft is not power of 2, this may effects computation time." 153 | warn(msg) 154 | if window: 155 | self.window = get_window(window, self.n_per_frame) 156 | else: 157 | self.window = np.ones((self.n_per_frame,)) 158 | self.nfilters = nfilters 159 | self.ncep = ncep # Number of cepstrum 160 | self.ceplifter = ceplifter # Lifter's cepstral coefficient 161 | # ------------------------------------------------- 162 | 163 | # Framing signal. 164 | pre_emp_sig = self.preemphasis(self.x, coeff=preemph) 165 | self.frames = signal_to_frame(pre_emp_sig, 166 | self.n_per_frame, self.hopsize, 167 | self.window) 168 | 169 | # Computer power spectrum 170 | # Magnitude of spectrum, rfft then np.abs() 171 | mspec = magspec(self.frames, self.nfft) 172 | pspec = 1.0 / self.nfft * np.square(mspec) # Power spectrum 173 | 174 | # Total energy of each frame based on the power spectrum 175 | self.frame_energy = np.sum(pspec, 1) 176 | # Replace 0 with the smallest float positive number 177 | self.frame_energy = np.where(self.frame_energy == 0, 178 | np.finfo(float).eps, 179 | self.frame_energy) 180 | 181 | # Prepare Mel filter 182 | # Use the default filter banks. 183 | self.filter_banks = Amfcc.mel_filterbanks(self.sr, 184 | nfilters=self.nfilters, 185 | nfft=self.nfft) 186 | 187 | # filter bank energies are the features. 188 | self.cepstra = np.dot(pspec, self.filter_banks.T) 189 | self.cepstra = np.where(self.cepstra == 0, 190 | np.finfo(float).eps, self.cepstra) 191 | self.cepstra = np.log(self.cepstra) 192 | 193 | # Discrete cosine transform 194 | self.cepstra = dct(self.cepstra, type=2, 195 | axis=1, norm='ortho')[:, :self.ncep] 196 | 197 | self.cepstra = Amfcc.lifter(self.cepstra, self.ceplifter) 198 | 199 | # Replace first cepstral coefficient with log of frame energy 200 | if append_energy: 201 | self.cepstra[:, 0] = np.log(self.frame_energy) 202 | 203 | @property 204 | def nframes(self): 205 | return self.frames.shape[0] 206 | 207 | @property 208 | def timestamp(self): 209 | return np.linspace(0, self.duration, self.nframes) 210 | 211 | @property 212 | def features(self): 213 | """The features refer to the cepstra""" 214 | return self.cepstra 215 | 216 | def __repr__(self): 217 | return f"Amfcc({self.label}): sr {self.sr}, length: {self.duration} s" 218 | 219 | @staticmethod 220 | def preemphasis(x: np.ndarray, coeff: float = 0.97): 221 | """Pre-emphasis filter to whiten the spectrum. 222 | Pre-emphasis is a way of compensating for the 223 | rapid decaying spectrum of speech. 224 | Can often skip this step in the cases of music for example 225 | 226 | Parameters 227 | ---------- 228 | x : numpy.ndarray 229 | Signal array 230 | coeff : float 231 | Preemphasis coefficient. The larger the stronger smoothing 232 | and the slower response to change. 233 | 234 | Returns 235 | ------- 236 | _ : numpy.ndarray 237 | The whitened signal. 238 | """ 239 | return np.append(x[0], x[1:] - coeff * x[:-1]) 240 | 241 | @staticmethod 242 | def mel_filterbanks(sr: int, nfilters: int = 26, nfft: int = 512, 243 | lowfreq: float = 0, highfreq: Optional[float] = None): 244 | """Compute a Mel-filterbank. The filters are stored in the rows, 245 | the columns correspond to fft bins. The filters are returned as 246 | an array of size nfilt * (nfft/2 + 1) 247 | 248 | Parameters 249 | ---------- 250 | sr : int 251 | Sampling rate 252 | nfilters : int 253 | The number of filters, default 20 254 | nfft : int 255 | The size of FFT, default 512 256 | lowfreq : float 257 | The lowest band edge of the mel filters, default 0 Hz 258 | highfreq : float 259 | The highest band edge of the mel filters, default sr // 2 260 | 261 | Returns 262 | ------- 263 | _ : numpy.ndarray 264 | A numpy array of size nfilt * (nfft/2 + 1) 265 | containing filterbank. Each row holds 1 filter. 266 | """ 267 | highfreq = highfreq or sr // 2 268 | 269 | # compute points evenly spaced in mels 270 | lowmel = hz_to_mel(lowfreq) 271 | highmel = hz_to_mel(highfreq) 272 | melpoints = np.linspace(lowmel, highmel, nfilters + 2) 273 | # our points are in Hz, but we use fft bins, so we have to convert 274 | # from Hz to fft bin number 275 | bin = np.floor((nfft + 1) * mel_to_hz(melpoints) / sr) 276 | 277 | filter_banks = np.zeros([nfilters, nfft // 2 + 1]) 278 | for j in range(0, nfilters): 279 | for i in range(int(bin[j]), int(bin[j + 1])): 280 | filter_banks[j, i] = (i - bin[j]) / (bin[j + 1] - bin[j]) 281 | for i in range(int(bin[j + 1]), int(bin[j + 2])): 282 | filter_banks[j, i] = (bin[j + 2] - i) / (bin[j + 2] - bin[j + 1]) 283 | return filter_banks 284 | 285 | @staticmethod 286 | def lifter(cepstra: np.ndarray, L: int = 22): 287 | """Apply a cepstral lifter the the matrix of cepstra. 288 | This has the effect of increasing the magnitude of 289 | the high frequency DCT coeffs. 290 | 291 | Liftering operation is similar to filtering operation in the 292 | frequency domain 293 | where a desired quefrency region for analysis is selected 294 | by multiplying the whole cepstrum 295 | by a rectangular window at the desired position. 296 | There are two types of liftering performed, 297 | low-time liftering and high-time liftering. 298 | Low-time liftering operation is performed to extract 299 | the vocal tract characteristics in the quefrency domain 300 | and high-time liftering is performed to get the excitation 301 | characteristics of the analysis speech frame. 302 | 303 | 304 | Parameters 305 | ---------- 306 | cepstra : numpy.ndarray 307 | The matrix of mel-cepstra 308 | L : int 309 | The liftering coefficient to use. Default is 22, 310 | since cepstra usually has 13 elements, 22 311 | L will result almost half pi of sine lift. 312 | It essential try to emphasis to lower ceptral coefficient 313 | while deemphasize higher ceptral coefficient as they are 314 | less discriminative for speech contents. 315 | """ 316 | if L > 0: 317 | nframes, ncoeff = np.shape(cepstra) 318 | n = np.arange(ncoeff) 319 | lift = 1 + (L / 2.) * np.sin(np.pi * n / L) 320 | return lift * cepstra 321 | else: 322 | # values of L <= 0, do nothing 323 | return cepstra 324 | 325 | def plot(self, show_bar: bool = True, offset: int = 0, scale: float = 1., 326 | xlim: Optional[float] = None, ylim: Optional[float] = None, 327 | x_as_time: bool = True, nxlabel: int = 8, ax=None, **kwargs): 328 | """Plot Amfcc.features via matshow, x is frames/time, y is the MFCCs 329 | 330 | Parameters 331 | ---------- 332 | show_bar : bool, optional 333 | Default is True, show colorbar. 334 | offset: int 335 | It is the spacing between channel, without setting it every channel will be overlayed onto each other. 336 | scale: float 337 | Visual scaling for improve visibility 338 | xlim: float, optional 339 | x axis value range limit 340 | ylim: float, optional 341 | y axis value range limit 342 | nxlabel : int, optional 343 | The amountt of labels on the x axis. Default is 8 . 344 | """ 345 | if self.channels > 1: 346 | warn("Multichannel mfcc is not yet implemented. Please use " 347 | "mono signal for now, no plot is made") 348 | return self 349 | im, ax = basicplot(self.cepstra.T, None, 350 | channels=self.channels, 351 | cn=self.cn, offset=offset, scale=scale, 352 | ax=ax, typ='mfcc', show_bar=show_bar, 353 | xlabel='time', xlim=xlim, ylim=ylim, **kwargs) 354 | self.im = im 355 | xticks = np.linspace(0, self.nframes, nxlabel, dtype=int) 356 | ax.set_xticks(xticks) 357 | # ax.set_("MFCC Coefficient") 358 | if x_as_time: 359 | xlabels = np.round(np.linspace(0, self.duration, nxlabel), 360 | decimals=2) 361 | # Replace x ticks with timestamps 362 | ax.set_xticklabels(xlabels) 363 | ax.xaxis.tick_bottom() 364 | # ax.set_xtitle("Time (s)") 365 | return self 366 | -------------------------------------------------------------------------------- /pya/arecorder.py: -------------------------------------------------------------------------------- 1 | # Arecorder class 2 | import logging 3 | import numbers 4 | import time 5 | from typing import Optional, Union 6 | 7 | import numpy as np 8 | from . import Asig 9 | from . import Aserver 10 | from pyamapping import db_to_amp 11 | 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | _LOGGER.addHandler(logging.NullHandler()) 15 | 16 | 17 | class Arecorder(Aserver): 18 | """pya audio recorder Based on pyaudio, uses callbacks to save audio data 19 | for pyaudio signals into ASigs 20 | 21 | Examples: 22 | ----------- 23 | >>> from pya import Arecorder 24 | >>> import time 25 | >>> ar = Arecorder().boot() 26 | >>> ar.record() 27 | >>> time.sleep(1) 28 | >>> ar.stop() 29 | >>> print(ar.recordings) # doctest:+ELLIPSIS 30 | [Asig(''): ... x ... @ 44100Hz = ... 31 | """ 32 | 33 | def __init__(self, sr: int = 44100, bs: int = 256, device: Optional[int] = None, 34 | channels: Optional[int] = None, backend=None, **kwargs): 35 | super().__init__(sr=sr, bs=bs, device=device, 36 | backend=backend, **kwargs) 37 | self.record_buffer = [] 38 | self.recordings = [] # store recorded Asigs, time stamp in label 39 | self._recording = False 40 | self._record_all = True 41 | self.tracks = slice(None) 42 | self._device = self.backend.get_default_input_device_info()['index'] if device is None else device 43 | self._channels = channels or self.max_in_chn 44 | self.gains = np.ones(self._channels) 45 | 46 | @property 47 | def channels(self): 48 | return self._channels 49 | 50 | @channels.setter 51 | def channels(self, val: int): 52 | """ 53 | Set the number of channels. Aserver needs reboot. 54 | """ 55 | if val > self.max_in_chn: 56 | raise ValueError(f"AServer: channels {val} > max {self.max_in_chn}") 57 | self._channels = val 58 | 59 | def set_tracks(self, tracks: Union[list, np.ndarray], gains: Union[list, np.ndarray]): 60 | """Define the number of track to be recorded and their gains. 61 | 62 | parameters 63 | ---------- 64 | tracks : list or numpy.ndarray 65 | A list of input channel indices. By default None (record all channels) 66 | gains : list of numpy.ndarray 67 | A list of gains in decibel. Needs to be same length as tracks. 68 | """ 69 | if isinstance(tracks, list) and isinstance(gains, list): 70 | if len(tracks) != len(gains): 71 | raise AttributeError("tracks and gains should be equal length lists.") 72 | elif len(tracks) > self.channels or len(gains) > self.channels: 73 | raise AttributeError("argument cannot be larger than channels.") 74 | self.tracks = tracks 75 | self.gains = np.array([db_to_amp(g) for g in gains], dtype="float32") 76 | elif isinstance(tracks, numbers.Number) and isinstance(gains, numbers.Number): 77 | self.tracks = [tracks] 78 | self.gains = db_to_amp(gains) 79 | else: 80 | raise TypeError("Arguments need to be both list or both number.") 81 | 82 | def reset(self): 83 | self.tracks = slice(None) 84 | self.gains = np.ones(self.channels) 85 | 86 | def boot(self): 87 | self.boot_time = time.time() 88 | self.block_time = self.boot_time 89 | # self.block_cnt = 0 90 | self.record_buffer = [] 91 | self._recording = False 92 | self.stream = self.backend.open(rate=self.sr, channels=self.channels, frames_per_buffer=self.bs, 93 | input_device_index=self.device, output_flag=False, 94 | input_flag=True, stream_callback=self._recorder_callback) 95 | _LOGGER.info("Server Booted") 96 | return self 97 | 98 | def _recorder_callback(self, in_data, frame_count, time_info, flag): 99 | """Callback function during streaming. """ 100 | # self.block_cnt += 1 101 | if self._recording: 102 | sigar = np.frombuffer(in_data, dtype=self.backend.dtype) 103 | # (chunk length, chns) 104 | data_float = np.reshape(sigar, (len(sigar) // self.channels, self.channels)) 105 | data_float = data_float[:, self.tracks] * self.gains # apply channel selection and gains. 106 | # if not self._record_all 107 | self.record_buffer.append(data_float) 108 | # E = 10 * np.log10(np.mean(data_float ** 2)) # energy in dB 109 | # os.write(1, f"\r{E} | {self.block_cnt}".encode()) 110 | return self.backend.process_buffer(None) 111 | 112 | def record(self): 113 | """Activate recording""" 114 | self._recording = True 115 | 116 | def pause(self): 117 | """Pause the recording, but the record_buffer remains""" 118 | self._recording = False 119 | 120 | def stop(self): 121 | """Stop recording, then stores the data from record_buffer into recordings""" 122 | self._recording = False 123 | if len(self.record_buffer) > 0: 124 | sig = np.squeeze(np.vstack(self.record_buffer)) 125 | self.recordings.append(Asig(sig, self.sr, label="")) 126 | self.record_buffer = [] 127 | else: 128 | _LOGGER.info(" Stopped. There is no recording in the record_buffer") 129 | 130 | def __repr__(self): 131 | state = False 132 | if self.stream: 133 | state = self.stream.is_active() 134 | msg = f"""Arecorder: sr: {self.sr}, blocksize: {self.bs}, Stream Active: {state} 135 | Input: {self.device_dict['name']}, Index: {self.device_dict['index']} 136 | """ 137 | return msg 138 | -------------------------------------------------------------------------------- /pya/aserver.py: -------------------------------------------------------------------------------- 1 | from .helper.backend import determine_backend 2 | import copy 3 | import logging 4 | import time 5 | from typing import Optional, Union 6 | from warnings import warn 7 | 8 | import numpy as np 9 | 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | _LOGGER.addHandler(logging.NullHandler()) 13 | 14 | 15 | class Aserver: 16 | """Pya audio server 17 | Based on pyaudio, works as a FIFO style audio stream pipeline, 18 | allowing Asig.play() to send audio segement into the stream. 19 | 20 | Examples: 21 | ----------- 22 | >>> from pya import * 23 | >>> ser = Aserver() 24 | >>> ser.boot() 25 | AServer: sr: 44100, blocksize: ..., 26 | Stream Active: True, Device: ... 27 | >>> asine = Ugen().sine() 28 | >>> asine.play(server=ser) 29 | Asig('sine'): 1 x 44100 @ 44100Hz = 1.000s cn=['0'] 30 | """ 31 | 32 | default = None # that's the default Aserver if Asigs play via it 33 | 34 | @staticmethod 35 | def startup_default_server(**kwargs): 36 | if Aserver.default is None: 37 | _LOGGER.info("Aserver startup_default_server: create and boot") 38 | Aserver.default = Aserver(**kwargs) # using all default settings 39 | Aserver.default.boot() 40 | _LOGGER.info("Default server info: %s", Aserver.default) 41 | else: 42 | _LOGGER.info("Aserver default_server already set.") 43 | return Aserver.default 44 | 45 | @staticmethod 46 | def shutdown_default_server(): 47 | if isinstance(Aserver.default, Aserver): 48 | Aserver.default.quit() 49 | del Aserver.default 50 | Aserver.default = None 51 | else: 52 | warn("Aserver:shutdown_default_server: no default_server to shutdown") 53 | 54 | def __init__(self, sr: int = 44100, bs: Optional[int] = None, 55 | device: Optional[int] = None, channels: Optional[int] = None, 56 | backend=None, **kwargs): 57 | """Aserver manages an pyaudio stream, using its aserver callback 58 | to feed dispatched signals to output at the right time. 59 | 60 | Parameters 61 | ---------- 62 | sr : int 63 | Sampling rate (Default value = 44100) 64 | bs : int 65 | Override block size or buffer size set by chosen backend 66 | device : int 67 | The device index based on pya.device_info(), default is None which will set 68 | the default device from PyAudio 69 | channels : int 70 | number of channel, default is the max output channels of the device 71 | kwargs : backend parameter 72 | 73 | Returns 74 | ------- 75 | _ : numpy.ndarray 76 | numpy array of the recorded audio signal. 77 | """ 78 | # TODO check if channels is overwritten by the device. 79 | self.sr = sr 80 | self.stream = None 81 | self.backend = determine_backend(**kwargs) if backend is None else backend 82 | self.bs = bs or self.backend.bs 83 | # Get audio devices to input_device and output_device 84 | self.input_devices = [] 85 | self.output_devices = [] 86 | for i in range(self.backend.get_device_count()): 87 | if int(self.backend.get_device_info_by_index(i)['maxInputChannels']) > 0: 88 | self.input_devices.append(self.backend.get_device_info_by_index(i)) 89 | if int(self.backend.get_device_info_by_index(i)['maxOutputChannels']) > 0: 90 | self.output_devices.append(self.backend.get_device_info_by_index(i)) 91 | 92 | self._device = self.backend.get_default_output_device_info()['index'] if device is None else device 93 | self._channels = channels or self.max_out_chn 94 | 95 | self.gain = 1.0 96 | self.srv_onsets = [] 97 | self.srv_curpos = [] # start of next frame to deliver 98 | self.srv_asigs = [] 99 | self.srv_outs = [] # output channel offset for that asig 100 | self.boot_time = 0 # time.time() when stream starts 101 | self.block_cnt = 0 # nr. of callback invocations 102 | self.block_duration = self.bs / self.sr # nominal time increment per callback 103 | self.block_time = 0 # estimated time stamp for current block 104 | self._stop = True 105 | self.empty_buffer = np.zeros((self.bs, self.channels), dtype=self.backend.dtype) 106 | self._is_active = False 107 | 108 | @property 109 | def channels(self): 110 | return self._channels 111 | 112 | @channels.setter 113 | def channels(self, val: int): 114 | """ 115 | Set the number of channels. Aserver needs reboot. 116 | """ 117 | if val > self.max_out_chn: 118 | raise ValueError(f"AServer: channels {val} > max {self.max_out_chn}") 119 | self._channels = val 120 | 121 | @property 122 | def device_dict(self): 123 | return self.backend.get_device_info_by_index(self._device) 124 | 125 | @property 126 | def max_out_chn(self) -> int: 127 | return int(self.device_dict['maxOutputChannels']) 128 | 129 | @property 130 | def max_in_chn(self) -> int: 131 | return int(self.device_dict['maxInputChannels']) 132 | 133 | @property 134 | def is_active(self) -> bool: 135 | return self.stream is not None and self.stream.is_active() 136 | 137 | @property 138 | def device(self): 139 | return self._device 140 | 141 | @device.setter 142 | def device(self, val): 143 | self._device = val if val is not None else self.backend.get_default_output_device_info()['index'] 144 | if self.max_out_chn < self.channels: 145 | warn(f"Aserver: warning: {self.channels}>{self.max_out_chn} channels requested - truncated.") 146 | self.channels = self.max_out_chn 147 | 148 | def __repr__(self): 149 | msg = f"""AServer: sr: {self.sr}, blocksize: {self.bs}, 150 | Stream Active: {self.is_active}, Device: {self.device_dict['name']}, Index: {self.device_dict['index']}""" 151 | return msg 152 | 153 | def get_devices(self, verbose: bool = False): 154 | """Return (and optionally print) available input and output device""" 155 | if verbose: 156 | print("Input Devices: ") 157 | [print(f"Index: {i['index']}, Name: {i['name']}, Channels: {i['maxInputChannels']}") 158 | for i in self.input_devices] 159 | print("Output Devices: ") 160 | [print(f"Index: {i['index']}, Name: {i['name']}, Channels: {i['maxOutputChannels']}") 161 | for i in self.output_devices] 162 | return self.input_devices, self.output_devices 163 | 164 | def set_device(self, idx: int, reboot: bool = True): 165 | """Set audio device, an alternative way is to direct set the device property, i.e. Aserver.device = 1, 166 | but that will not reboot the server. 167 | 168 | Parameters 169 | ---------- 170 | idx : int 171 | Index of the device 172 | reboot : bool 173 | If true the server will reboot. (Default value = True) 174 | """ 175 | self._device = idx 176 | if reboot: 177 | try: 178 | self.quit() 179 | except AttributeError: 180 | _LOGGER.warning(" Reboot while no active stream") 181 | try: 182 | self.boot() 183 | except OSError: 184 | raise OSError("Error: Invalid device. Server did not boot.") 185 | 186 | def boot(self): 187 | """boot Aserver = start stream, setting its callback to this callback.""" 188 | if self.is_active: 189 | _LOGGER.info("Aserver already running...") 190 | return -1 191 | self.boot_time = time.time() 192 | self.block_time = self.boot_time 193 | self.block_cnt = 0 194 | self.stream = self.backend.open(channels=self.channels, rate=self.sr, 195 | input_flag=False, output_flag=True, 196 | frames_per_buffer=self.bs, 197 | output_device_index=self.device, 198 | stream_callback=self._play_callback) 199 | self._is_active = self.stream.is_active() 200 | _LOGGER.info("Server Booted") 201 | return self 202 | 203 | def quit(self): 204 | """Aserver quit server: stop stream and terminate pa""" 205 | if not self.is_active: 206 | _LOGGER.info("Stream not active") 207 | return -1 208 | try: 209 | if self.stream: 210 | self.stream.stop_stream() 211 | self.stream.close() 212 | _LOGGER.info("Aserver stopped.") 213 | except AttributeError: 214 | _LOGGER.info("No stream found...") 215 | self.stream = None 216 | return 0 217 | 218 | def play(self, asig, onset: Union[int, float] = 0, out: int = 0, **kwargs): 219 | """Dispatch asigs or arrays for given onset. 220 | 221 | asig: pya.Asig 222 | An Asig object 223 | onset: int or float 224 | Time when the sound should play, 0 means asap 225 | out: int 226 | Output channel 227 | """ 228 | self._stop = False 229 | 230 | sigid = id(asig) # for copy check 231 | if asig.sr != self.sr: 232 | asig = asig.resample(self.sr) 233 | if onset < 1e6: 234 | rt_onset = time.time() + onset 235 | else: 236 | rt_onset = onset 237 | idx = np.searchsorted(self.srv_onsets, rt_onset) 238 | self.srv_onsets.insert(idx, rt_onset) 239 | if asig.sig.dtype != self.backend.dtype: 240 | warn("Not the same type. ") 241 | if id(asig) == sigid: 242 | asig = copy.copy(asig) 243 | asig.sig = asig.sig.astype(self.backend.dtype) 244 | # copy only relevant channels... 245 | nchn = min(asig.channels, self.channels - out) # max number of copyable channels 246 | # in: [:nchn] out: [out:out+nchn] 247 | if id(asig) == sigid: 248 | asig = copy.copy(asig) 249 | if len(asig.sig.shape) == 1: 250 | asig.sig = asig.sig.reshape(asig.samples, 1) 251 | asig.sig = asig.sig[:, :nchn].reshape(asig.samples, nchn) 252 | # asig.channels = nchn 253 | # so now in callback safely copy to out:out+asig.sig.shape[1] 254 | self.srv_asigs.insert(idx, asig) 255 | self.srv_curpos.insert(idx, 0) 256 | self.srv_outs.insert(idx, out) 257 | if 'block' in kwargs and kwargs['block']: 258 | if onset > 0: # here really omset and not rt_onset! 259 | _LOGGER.warning("blocking inactive with play(onset>0)") 260 | else: 261 | time.sleep(asig.get_duration()) 262 | return self 263 | 264 | def _play_callback(self, in_data, frame_count, time_info, flag): 265 | """callback function, called from pastream thread when data needed.""" 266 | tnow = self.block_time 267 | self.block_time += self.block_duration 268 | # self.block_cnt += 1 # TODO this will get very large eventually 269 | # just curious - not needed but for time stability check 270 | self.timejitter = time.time() - self.block_time 271 | if self.timejitter > 3 * self.block_duration: 272 | msg = f"Aserver late by {self.timejitter} seconds: block_time reset!" 273 | _LOGGER.debug(msg) 274 | self.block_time = time.time() 275 | # to shortcut computing 276 | if not self.srv_asigs or self.srv_onsets[0] > tnow: 277 | return self.backend.process_buffer(self.empty_buffer) 278 | elif self._stop: 279 | self.srv_asigs.clear() 280 | self.srv_onsets.clear() 281 | self.srv_curpos.clear() 282 | self.srv_outs.clear() 283 | return self.backend.process_buffer(self.empty_buffer) 284 | data = np.zeros((self.bs, self.channels), dtype=self.backend.dtype) 285 | # iterate through all registered asigs, adding samples to play 286 | dellist = [] # memorize completed items for deletion 287 | t_next_block = tnow + self.bs / self.sr 288 | for i, t in enumerate(self.srv_onsets): 289 | if t > t_next_block: # doesn't begin before next block 290 | break # since list is always onset-sorted 291 | a = self.srv_asigs[i] 292 | c = self.srv_curpos[i] 293 | if t > tnow: # first block: apply precise zero padding 294 | io0 = int((t - tnow) * self.sr) 295 | else: 296 | io0 = 0 297 | tmpsig = a.sig[c:c + self.bs - io0] 298 | n, nch = tmpsig.shape 299 | out = self.srv_outs[i] 300 | # .reshape(n, nch) not needed as moved to play 301 | data[io0:io0 + n, out:out + nch] += tmpsig 302 | self.srv_curpos[i] += n 303 | if self.srv_curpos[i] >= a.samples: 304 | dellist.append(i) # store for deletion 305 | # clean up lists 306 | for i in dellist[::-1]: # traverse backwards! 307 | del self.srv_asigs[i] 308 | del self.srv_onsets[i] 309 | del self.srv_curpos[i] 310 | del self.srv_outs[i] 311 | return self.backend.process_buffer(data * (self.backend.range * self.gain)) 312 | 313 | def stop(self): 314 | self._stop = True 315 | 316 | def __enter__(self): 317 | return self.boot() 318 | 319 | def __exit__(self, exc_type, exc_value, traceback): 320 | self.quit() 321 | self.backend.terminate() 322 | 323 | def __del__(self): 324 | self.quit() 325 | self.backend.terminate() 326 | 327 | -------------------------------------------------------------------------------- /pya/aspec.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Union, Optional 3 | 4 | import numpy as np 5 | import scipy.interpolate 6 | 7 | import pya.asig 8 | from .helper import basicplot 9 | 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | _LOGGER.addHandler(logging.NullHandler()) 13 | 14 | 15 | class Aspec: 16 | """Audio spectrum class using rfft""" 17 | def __init__(self, x: Union[pya.asig.Asig, np.ndarray], sr: int = 44100, 18 | label: Optional[str] = None, cn: Optional[list] = None): 19 | """__init__() method 20 | Parameters 21 | ---------- 22 | x : Asig or numpy.ndarray 23 | audio signal 24 | sr : int 25 | sampling rate (Default value = 44100) 26 | label : str or None 27 | Asig label (Default value = None) 28 | cn : list or Nonpya.asige 29 | Channel names (Default value = None) 30 | """ 31 | if isinstance(x, pya.asig.Asig): 32 | self.sr = x.sr 33 | self.rfftspec = np.fft.rfft(x.sig, axis=0) 34 | self.label = x.label + "_spec" 35 | self.samples = x.samples 36 | self.channels = x.channels 37 | self.cn = cn or x.cn 38 | elif isinstance(x, np.ndarray): 39 | # TODO. This is in the assumption x is spec. which is wrong. We define x to be the audio signals instead. 40 | self.rfftspec = np.array(x) 41 | self.sr = sr 42 | self.samples = (len(x) - 1) * 2 43 | self.channels = x.ndim 44 | else: 45 | s = "argument x must be an Asig or an array" 46 | raise TypeError(s) 47 | if label: 48 | self.label = label 49 | if cn: 50 | self.cn = cn 51 | self.nr_freqs = self.samples // 2 + 1 52 | self.freqs = np.linspace(0, self.sr / 2, self.nr_freqs) 53 | 54 | def get_duration(self): 55 | """Return the duration in second.""" 56 | return self.samples / self.sr 57 | 58 | def to_sig(self): 59 | """Convert Aspec into Asig""" 60 | return pya.asig.Asig(np.fft.irfft(self.rfftspec), 61 | sr=self.sr, label=self.label + '_2sig', cn=self.cn) 62 | 63 | def weight(self, weights: list, freqs=None, curve=1, kind='linear'): 64 | """TODO 65 | 66 | Parameters 67 | ---------- 68 | weights : 69 | 70 | freqs : 71 | (Default value = None) 72 | curve : 73 | (Default value = 1) 74 | kind : 75 | (Default value = 'linear') 76 | 77 | Returns 78 | ------- 79 | 80 | """ 81 | nfreqs = len(weights) 82 | if not freqs: 83 | given_freqs = np.linspace(0, self.freqs[-1], nfreqs) 84 | else: 85 | if nfreqs != len(freqs): 86 | raise AttributeError("Size of weights and freqs are not equal") 87 | 88 | if not all(freqs[i] < freqs[i + 1] for i in range(len(freqs) - 1)): 89 | raise AttributeError("Aspec.weight error: freqs not sorted") 90 | # check if list is monotonous 91 | if freqs[0] > 0: 92 | freqs = np.insert(np.array(freqs), 0, 0) 93 | weights = np.insert(np.array(weights), 0, weights[0]) 94 | if freqs[-1] < self.sr / 2: 95 | freqs = np.insert(np.array(freqs), -1, self.sr / 2) 96 | weights = np.insert(np.array(weights), -1, weights[-1]) 97 | given_freqs = freqs 98 | if nfreqs != self.nr_freqs: 99 | interp_fn = scipy.interpolate.interp1d(given_freqs, 100 | weights, kind=kind) 101 | # ToDo: curve segmentwise!!! 102 | rfft_new = self.rfftspec * interp_fn(self.freqs) ** curve 103 | else: 104 | rfft_new = self.rfftspec * weights ** curve 105 | return Aspec(rfft_new, self.sr, 106 | label=self.label + "_weighted", cn=self.cn) 107 | 108 | def plot(self, fn=np.abs, ax=None, 109 | offset=0, scale=1, 110 | xlim=None, ylim=None, **kwargs): 111 | """Plot spectrum 112 | 113 | Parameters 114 | ---------- 115 | fn : func 116 | function for processing the rfft spectrum. (Default value = np.abs) 117 | x_as_time : bool, optional 118 | By default x axis display the time, if faulse display samples 119 | xlim : tuple or list or None 120 | Set x axis range (Default value = None) 121 | ylim : tuple or list or None 122 | Set y axis range (Default value = None) 123 | offset : int or float 124 | This is the absolute value each plot is shift vertically 125 | to each other. 126 | scale : float 127 | Scaling factor of the plot, use in multichannel plotting. 128 | **kwargs : 129 | Keyword arguments for matplotlib.pyplot.plot() 130 | Returns 131 | ------- 132 | _ : Asig 133 | self 134 | """ 135 | _, ax = basicplot(fn(self.rfftspec), self.freqs, channels=self.channels, 136 | cn=self.cn, offset=offset, scale=scale, 137 | ax=ax, typ='plot', 138 | xlabel='freq (Hz)', ylabel=f'{fn.__name__}(freq)', 139 | xlim=xlim, ylim=ylim, **kwargs) 140 | return self 141 | 142 | def __repr__(self): 143 | return "Aspec('{}'): {} x {} @ {} Hz = {:.3f} s".format( 144 | self.label, self.channels, self.samples, 145 | self.sr, self.samples / self.sr) 146 | -------------------------------------------------------------------------------- /pya/astft.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import logging 3 | from typing import Union, Optional 4 | 5 | import numpy as np 6 | from scipy.signal import stft, istft 7 | 8 | import pya.asig 9 | from .helper import basicplot 10 | 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | _LOGGER.addHandler(logging.NullHandler()) 14 | 15 | 16 | # TODO, check with multichannel 17 | class Astft: 18 | """Audio spectrogram (STFT) class, attributes refers to scipy.signal.stft. With an addition 19 | attribute cn being the list of channel names, and label being the name of the Asig 20 | """ 21 | 22 | def __init__(self, x: Union[pya.asig.Asig, np.ndarray], sr: Optional[int] = None, label: str = "STFT", 23 | window: str = 'hann', nperseg: int = 256, noverlap: Optional[int] = None, 24 | nfft: Optional[int] = None, detrend: bool = False, 25 | return_onesided: bool = True, boundary: str = 'zeros', 26 | padded: bool = True, cn: Optional[list] = None): 27 | """__init__() method 28 | 29 | Parameters 30 | ---------- 31 | x : Asig or numpy.ndarray 32 | x can be two forms, the most commonly used is an Asig object. 33 | Such as directly acquired from an Asig object via Asig.to_stft(). 34 | sr : int, optional 35 | sampling rate, this is only necessary if x is not Asig. 36 | (Default value = None) 37 | window : str 38 | type of the window function (Default value = 'hann') 39 | nperseg : int 40 | number of samples per stft segment (Default value = '256') 41 | noverlap : int, optional 42 | number of samples to overlap between segments (Default value = None) 43 | nfft : int, optional 44 | Length of the FFT used, if a zero padded FFT is desired. If 45 | `None`, the FFT length is `nperseg`. Defaults to `None`. 46 | detrend : str or function or bool 47 | Specifies how to detrend each segment. If detrend is a string, 48 | it is passed as the type argument to the detrend function. If it is a function, 49 | it takes a segment and returns a detrended segment. If detrend is False, 50 | no detrending is done. (Default value = False). 51 | return_onesided : bool 52 | If True, return a one-sided spectrum for real data. If False return a two-sided spectrum. 53 | Defaults to True, but for complex data, a two-sided spectrum is always returned. (Default value = True) 54 | boundary : str or None 55 | Specifies whether the input signal is extended at both ends, and how to generate the new values, 56 | in order to center the first windowed segment on the first input point. 57 | This has the benefit of enabling reconstruction of the first input point 58 | when the employed window function starts at zero. 59 | Valid options are ['even', 'odd', 'constant', 'zeros', None]. Defaults to ‘zeros’, 60 | for zero padding extension. I.e. [1, 2, 3, 4] is extended to [0, 1, 2, 3, 4, 0] for nperseg=3. (Default value = 'zeros') 61 | padded : bool 62 | Specifies whether the input signal is zero-padded at the end to make the signal fit exactly into 63 | an integer number of window segments, so that all of the signal is included in the output. 64 | Defaults to True. Padding occurs after boundary extension, if boundary is not None, and padded is True, 65 | as is the default. (Default value = True) 66 | cn : list, optional 67 | Channel names of the Asig, this will be used for the Astft for consistency. (Default value = None) 68 | """ 69 | self.window = window 70 | self.nperseg = nperseg 71 | self.noverlap = noverlap 72 | self.nfft = nfft 73 | self.detrend = detrend 74 | self.return_onesided = return_onesided 75 | self.boundary = boundary 76 | self.padded = padded 77 | # self.cn = cn 78 | self.im = None # buffer for the image 79 | 80 | if isinstance(x, pya.asig.Asig): 81 | self.sr = x.sr 82 | self.channels = x.channels 83 | self.label = x.label + "_stft" 84 | self.cn = x.cn 85 | self.samples = x.samples 86 | if sr: 87 | self.sr = sr # explicitly given sr overwrites Asig 88 | sig = x.sig 89 | 90 | elif isinstance(x, np.ndarray): 91 | # x is a numpy array instead of asig. 92 | self.channels = x.ndim 93 | self.samples = len(x) 94 | self.label = 'stft' 95 | self.cn = [] 96 | if sr is None: 97 | raise AttributeError("sr (sampling rate) is required as an argument if input is a numpy array rather than Asig.") 98 | else: 99 | self.sr = sr 100 | sig = x 101 | 102 | else: 103 | raise TypeError("Unknown data type x, x should be either Asig or numpy.ndarray") 104 | 105 | self.freqs, self.times, self.stft = \ 106 | stft(sig, fs=self.sr, window=window, nperseg=nperseg, 107 | noverlap=noverlap, nfft=nfft, detrend=detrend, 108 | return_onesided=return_onesided, boundary=boundary, 109 | padded=padded, axis=0) 110 | 111 | if cn: 112 | if len(cn) == self.channels: 113 | self.cn = cn 114 | else: 115 | raise AttributeError("Length of cn should equal channels.") 116 | 117 | def to_sig(self, **kwargs): 118 | """Create signal from stft, i.e. perform istft, 119 | kwargs overwrite Astft values for istft 120 | 121 | Parameters 122 | ---------- 123 | **kwargs : str 124 | optional keyboard arguments used in istft: 125 | 'sr', 'window', 'nperseg', 'noverlap', 126 | 'nfft', 'input_onesided', 'boundary'. 127 | also convert 'sr' to 'fs' since scipy uses 'fs' as sampling frequency. 128 | 129 | Returns 130 | ------- 131 | _ : Asig 132 | Asig 133 | """ 134 | for k in ['sr', 'window', 'nperseg', 'noverlap', 135 | 'nfft', 'input_onesided', 'boundary']: 136 | if k in kwargs.keys(): 137 | kwargs[k] = self.__getattribute__(k) 138 | if 'sr' in kwargs.keys(): 139 | kwargs['fs'] = kwargs['sr'] 140 | del kwargs['sr'] 141 | if self.channels == 1: 142 | # _ since 1st return value 'times' unused 143 | _, sig = istft(self.stft, **kwargs) 144 | return pya.asig.Asig(sig, sr=self.sr, 145 | label=self.label + '_2sig', cn=self.cn) 146 | else: 147 | _, sig = istft(self.stft, **kwargs) 148 | return pya.asig.Asig(np.transpose(sig), 149 | sr=self.sr, label=self.label + '_2sig', cn=self.cn) 150 | 151 | def plot(self, fn=lambda x: x, ax=None, 152 | offset=0, scale=1., xlim=None, ylim=None, 153 | show_bar=True, **kwargs): 154 | """Plot spectrogram 155 | 156 | Parameters 157 | ---------- 158 | fn : func 159 | a function, by default is bypass 160 | ch : int or str or None 161 | By default it is None, 162 | ax : matplotlib.axes 163 | you can assign your plot to specific axes (Default value = None) 164 | xlim : tuple or list 165 | x_axis range (Default value = None) 166 | ylim : tuple or list 167 | y_axis range (Default value = None) 168 | **kwargs : 169 | keyward arguments of matplotlib's pcolormesh 170 | 171 | Returns 172 | ------- 173 | _ : Asig 174 | self 175 | """ 176 | self.im, ax = basicplot(fn(np.abs(self.stft)), (self.times, self.freqs), 177 | channels=self.channels, 178 | cn=self.cn, offset=offset, scale=scale, 179 | ax=ax, typ='spectrogram', show_bar=show_bar, 180 | xlabel='time', xlim=xlim, ylim=ylim, **kwargs) 181 | ax.set_ylabel('freq') 182 | return self 183 | 184 | def __repr__(self): 185 | return "Astft('{}'): {} x {} @ {} Hz = {:.3f} s cn={}".format( 186 | self.label, self.channels, self.samples, 187 | self.sr, self.samples / self.sr, self.cn) 188 | -------------------------------------------------------------------------------- /pya/backend/Dummy.py: -------------------------------------------------------------------------------- 1 | from .base import BackendBase, StreamBase 2 | import numpy as np 3 | from threading import Thread 4 | import time 5 | 6 | 7 | class DummyBackend(BackendBase): 8 | 9 | dtype = 'float32' 10 | range = 1 11 | bs = 256 12 | 13 | def __init__(self): 14 | self.dummy_devices = [dict(maxInputChannels=10, maxOutputChannels=10, index=0, name="DummyDevice")] 15 | 16 | def get_device_count(self): 17 | return len(self.dummy_devices) 18 | 19 | def get_device_info_by_index(self, idx): 20 | return self.dummy_devices[idx] 21 | 22 | def get_default_input_device_info(self): 23 | return self.dummy_devices[0] 24 | 25 | def get_default_output_device_info(self): 26 | return self.dummy_devices[0] 27 | 28 | def open(self, *args, input_flag, output_flag, rate, frames_per_buffer, channels, stream_callback=None, **kwargs): 29 | checker = 'maxInputChannels' if input_flag else 'maxOutputChannels' 30 | if channels > self.dummy_devices[0][checker]: 31 | raise OSError("[Errno -9998] Invalid number of channels") 32 | stream = DummyStream(input_flag=input_flag, output_flag=output_flag, 33 | rate=rate, frames_per_buffer=frames_per_buffer, channels=channels, 34 | stream_callback=stream_callback) 35 | stream.start_stream() 36 | return stream 37 | 38 | def process_buffer(self, buffer): 39 | return buffer 40 | 41 | def terminate(self): 42 | pass 43 | 44 | 45 | class DummyStream(StreamBase): 46 | 47 | def __init__(self, input_flag, output_flag, frames_per_buffer, rate, channels, stream_callback): 48 | self.input_flag = input_flag 49 | self.output_flag = output_flag 50 | self.rate = rate 51 | self.stream_callback = stream_callback 52 | self.frames_per_buffer = frames_per_buffer 53 | self.channels = channels 54 | self._is_active = False 55 | self.in_thread = None 56 | self.out_thread = None 57 | self.samples_out = [] 58 | 59 | def stop_stream(self): 60 | self._is_active = False 61 | while (self.in_thread and self.in_thread.is_alive()) or \ 62 | (self.out_thread and self.out_thread.is_alive()): 63 | time.sleep(0.1) 64 | 65 | def close(self): 66 | self.stop_stream() 67 | 68 | def _generate_data(self): 69 | while self._is_active: 70 | sig = np.zeros(self.frames_per_buffer * self.channels, dtype=DummyBackend.dtype) 71 | self.stream_callback(sig, frame_count=None, time_info=None, flag=None) 72 | time.sleep(0.05) 73 | 74 | def _process_data(self): 75 | while self._is_active: 76 | data = self.stream_callback(None, None, None, None) 77 | if np.any(data): 78 | self.samples_out.append(data) 79 | time.sleep(0.05) 80 | 81 | def start_stream(self): 82 | self._is_active = True 83 | if self.input_flag: 84 | self.in_thread = Thread(target=self._generate_data) 85 | self.in_thread.daemon = True 86 | self.in_thread.start() 87 | if self.output_flag: 88 | self.out_thread = Thread(target=self._process_data) 89 | self.out_thread.daemon = True 90 | self.out_thread.start() 91 | 92 | def is_active(self): 93 | return self._is_active 94 | -------------------------------------------------------------------------------- /pya/backend/Jupyter.py: -------------------------------------------------------------------------------- 1 | from .base import BackendBase, StreamBase 2 | 3 | import asyncio 4 | import threading 5 | from functools import partial 6 | from IPython.display import Javascript, HTML, display 7 | 8 | try: 9 | import websockets 10 | except ImportError: 11 | websockets = None 12 | 13 | 14 | class JupyterBackend(BackendBase): 15 | 16 | dtype = 'float32' 17 | range = 1 18 | bs = 4096 # streaming introduces lack which has to be covered by the buffer 19 | 20 | def __init__(self, port=8765, proxy_suffix=None): 21 | if not websockets: 22 | raise Exception("JupyterBackend requires 'websockets' but it could not be imported. " 23 | "Did you miss installing optional 'remote' requirements?") 24 | 25 | self.dummy_devices = [dict(maxInputChannels=0, maxOutputChannels=2, index=0, name="JupyterBackend")] 26 | self.port = port 27 | self.proxy_suffix = proxy_suffix 28 | if self.proxy_suffix is not None: 29 | self.bs = 1024 * 10 # probably running on binder; increase buffer size 30 | 31 | def get_device_count(self): 32 | return len(self.dummy_devices) 33 | 34 | def get_device_info_by_index(self, idx): 35 | return self.dummy_devices[idx] 36 | 37 | def get_default_input_device_info(self): 38 | return self.dummy_devices[0] 39 | 40 | def get_default_output_device_info(self): 41 | return self.dummy_devices[0] 42 | 43 | def open(self, *args, channels, rate, stream_callback=None, **kwargs): 44 | display(HTML("
You are using the experimental Jupyter backend. " 45 | "Note that this backend is not feature complete and does not support recording so far. " 46 | "User experience may vary depending on the network latency.
")) 47 | stream = JupyterStream(channels=channels, rate=rate, stream_callback=stream_callback, port=self.port, 48 | proxy_suffix=self.proxy_suffix) 49 | stream.start_stream() 50 | return stream 51 | 52 | def process_buffer(self, buffer): 53 | return buffer 54 | 55 | def terminate(self): 56 | pass 57 | 58 | 59 | class JupyterStream(StreamBase): 60 | 61 | def __init__(self, channels, rate, stream_callback, port, proxy_suffix): 62 | self.rate = rate 63 | self.channels = channels 64 | self.stream_callback = stream_callback 65 | self.server = None 66 | self._is_active = False 67 | 68 | async def bridge(websocket): 69 | async for _ in websocket: 70 | buffer = self.stream_callback(None, None, None, None) 71 | # print(buffer) 72 | await websocket.send(buffer.reshape(-1, 1, order='F').tobytes()) 73 | 74 | async def ws_runner(): 75 | async with websockets.serve(bridge, "0.0.0.0", 8765): 76 | await asyncio.Future() 77 | 78 | def loop_thread(loop): 79 | # since ws_runner will block forever it will raise a runtime excepion 80 | # when we kill the event loop. 81 | try: 82 | loop.run_until_complete(ws_runner()) 83 | except RuntimeError: 84 | pass 85 | 86 | self.loop = asyncio.new_event_loop() 87 | self.thread = threading.Thread(target=loop_thread, args=(self.loop,)) 88 | # self.thread.daemon = True # allow program to shutdown even if the thread is alive 89 | 90 | url_suffix = f':{port}' if proxy_suffix is None else proxy_suffix 91 | 92 | self.client = Javascript( 93 | f""" 94 | var sampleRate = {self.rate}; 95 | var channels = {self.channels}; 96 | var urlSuffix = "{url_suffix}"; 97 | window.pya = {{ bufferThresh: 0.2 }} 98 | """ 99 | r""" 100 | var processedPackages = 0; 101 | var latePackages = 0; 102 | var badPackageRatio = 1; 103 | 104 | function resolveProxy() { 105 | let reg = /\/notebooks.*ipynb/g 106 | let res = window.location.pathname.replace(reg, ""); 107 | return res 108 | } 109 | 110 | var protocol = (window.location.protocol == 'https:') ? 'wss://' : 'ws://' 111 | var startTime = 0; 112 | var context = new (window.AudioContext || window.webkitAudioContext)(); 113 | 114 | context.onstatechange = function() { 115 | console.log("PyaJSClient: AudioContext StateChange!") 116 | if (context.state == "running") { 117 | var ws = new WebSocket(protocol+window.location.hostname+resolveProxy()+urlSuffix); 118 | ws.binaryType = 'arraybuffer'; 119 | window.ws = ws; 120 | 121 | ws.onopen = function() { 122 | console.log("PyaJSClient: Websocket connected."); 123 | startTime = context.currentTime; 124 | ws.send("G"); 125 | }; 126 | 127 | ws.onmessage = function (evt) { 128 | if (evt.data) { 129 | processedPackages++; 130 | var buf = new Float32Array(evt.data) 131 | var duration = buf.length / channels 132 | var buffer = context.createBuffer(channels, duration, sampleRate) 133 | for (let i = 0; i < channels; i++) { 134 | updateChannel(buffer, buf.slice(i * duration, (i + 1) * duration), i) 135 | } 136 | var source = context.createBufferSource() 137 | source.buffer = buffer 138 | source.connect(context.destination) 139 | if (startTime > context.currentTime) { 140 | source.start(startTime) 141 | startTime += buffer.duration 142 | } else { 143 | latePackages++; 144 | badPackageRatio = latePackages / processedPackages 145 | if (processedPackages > 50) { 146 | console.log("PyaJSClient: Dropped sample ratio is " + badPackageRatio.toFixed(2)) 147 | if (badPackageRatio > 0.05) { 148 | let tr = window.pya.bufferThresh 149 | window.pya.bufferThresh = (tr > 0.01) ? tr - 0.03 : 0.01; 150 | console.log("PyaJSClient: Decrease buffer delay to " + window.pya.bufferThresh.toFixed(2)) 151 | } 152 | latePackages = 0; 153 | processedPackages = 0; 154 | } 155 | source.start() 156 | startTime = context.currentTime + buffer.duration 157 | } 158 | setTimeout(function() {ws.send("G")}, 159 | (startTime - context.currentTime) * 1000 * window.pya.bufferThresh) 160 | } 161 | }; 162 | } 163 | }; 164 | 165 | var updateChannel = function(buffer, data, channelId) { 166 | buffer.copyToChannel(data, channelId, 0) 167 | } 168 | 169 | // Fallback for browsers without copyToChannel Support 170 | if (! AudioBuffer.prototype.copyToChannel) { 171 | console.log("PyaJSClient: AudioBuffer.copyToChannel not supported. Falling back...") 172 | updateChannel = function(buffer, data, channelId) { 173 | buffer.getChannelData(channelId).set(data); 174 | } 175 | } 176 | 177 | function resumeContext() { 178 | context.resume(); 179 | var codeCells = document.getElementsByClassName("input_area") 180 | for (var i = 0; i < codeCells.length; i++) { 181 | codeCells[i].removeEventListener("focusin", resumeContext) 182 | } 183 | } 184 | 185 | if (context.state == "suspended") { 186 | console.log("PyaJSClient: AudioContext not running. Waiting for user input...") 187 | var codeCells = document.getElementsByClassName("input_area") 188 | for (var i = 0; i < codeCells.length; i++) { 189 | codeCells[i].addEventListener("focusin", resumeContext) 190 | } 191 | } 192 | 193 | console.log("PyaJSClient: Websocket client loaded.") 194 | """) 195 | 196 | @staticmethod 197 | def set_buffer_threshold(buffer_limit): 198 | display(Javascript(f"window.pya.bufferThresh = {1 - buffer_limit}")) 199 | 200 | def stop_stream(self): 201 | if self.thread.is_alive(): 202 | self.loop.call_soon_threadsafe(self.loop.stop) 203 | self.thread.join() 204 | 205 | def close(self): 206 | self.stop_stream() 207 | self.loop.close() 208 | 209 | def start_stream(self): 210 | if not self.thread.is_alive(): 211 | self.thread.start() 212 | display(self.client) 213 | 214 | def is_active(self): 215 | return self.thread.is_alive() 216 | -------------------------------------------------------------------------------- /pya/backend/PyAudio.py: -------------------------------------------------------------------------------- 1 | from .base import BackendBase 2 | 3 | import pyaudio 4 | import time 5 | 6 | 7 | class PyAudioBackend(BackendBase): 8 | 9 | _boot_delay = 0.5 # a short delay to prevent PyAudio racing conditions 10 | bs = 512 11 | 12 | def __init__(self, format=pyaudio.paFloat32): 13 | self.pa = pyaudio.PyAudio() 14 | self.format = format 15 | if format == pyaudio.paInt16: 16 | self.dtype = 'int16' 17 | self.range = 32767 18 | elif format == pyaudio.paFloat32: 19 | self.dtype = 'float32' # for pyaudio.paFloat32 20 | self.range = 1.0 21 | else: 22 | raise AttributeError(f"Aserver: currently unsupported pyaudio format {self.format}") 23 | 24 | def get_device_count(self): 25 | return self.pa.get_device_count() 26 | 27 | def get_device_info_by_index(self, idx): 28 | return self.pa.get_device_info_by_index(idx) 29 | 30 | def get_default_input_device_info(self): 31 | return self.pa.get_default_input_device_info() 32 | 33 | def get_default_output_device_info(self): 34 | return self.pa.get_default_output_device_info() 35 | 36 | def open(self, rate, channels, input_flag, output_flag, frames_per_buffer, 37 | input_device_index=None, output_device_index=None, start=True, 38 | input_host_api_specific_stream_info=None, output_host_api_specific_stream_info=None, 39 | stream_callback=None): 40 | stream = self.pa.open(rate=rate, channels=channels, format=self.format, input=input_flag, output=output_flag, 41 | input_device_index=input_device_index, output_device_index=output_device_index, 42 | frames_per_buffer=frames_per_buffer, start=start, 43 | input_host_api_specific_stream_info=input_host_api_specific_stream_info, 44 | output_host_api_specific_stream_info=output_host_api_specific_stream_info, 45 | stream_callback=stream_callback) 46 | time.sleep(self._boot_delay) # give stream some time to be opened completely 47 | return stream 48 | 49 | def process_buffer(self, buffer): 50 | return buffer, pyaudio.paContinue 51 | 52 | def terminate(self): 53 | if self.pa: 54 | self.pa.terminate() 55 | self.pa = None 56 | -------------------------------------------------------------------------------- /pya/backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .Dummy import DummyBackend 2 | import logging 3 | 4 | _LOGGER = logging.getLogger(__name__) 5 | _LOGGER.addHandler(logging.NullHandler()) 6 | 7 | 8 | try: 9 | from .Jupyter import JupyterBackend 10 | except ImportError: # pragma: no cover 11 | _LOGGER.warning("Jupyter backend not found.") 12 | pass 13 | 14 | try: 15 | from .PyAudio import PyAudioBackend 16 | except ImportError: # pragma: no cover 17 | _LOGGER.warning("PyAudio backend not found.") 18 | pass 19 | 20 | 21 | from ..helper.backend import determine_backend 22 | -------------------------------------------------------------------------------- /pya/backend/base.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod, ABC 2 | 3 | 4 | class BackendBase(ABC): 5 | 6 | @abstractmethod 7 | def get_device_count(self): 8 | raise NotImplementedError 9 | 10 | @abstractmethod 11 | def get_device_info_by_index(self): 12 | raise NotImplementedError 13 | 14 | @abstractmethod 15 | def get_default_output_device_info(self): 16 | raise NotImplementedError 17 | 18 | @abstractmethod 19 | def get_default_input_device_info(self): 20 | raise NotImplementedError 21 | 22 | @abstractmethod 23 | def open(self, *args, **kwargs): 24 | raise NotImplementedError 25 | 26 | @abstractmethod 27 | def terminate(self): 28 | raise NotImplementedError 29 | 30 | @abstractmethod 31 | def process_buffer(self, *args, **kwargs): 32 | raise NotImplementedError 33 | 34 | 35 | class StreamBase(ABC): 36 | 37 | @abstractmethod 38 | def is_active(self): 39 | raise NotImplementedError 40 | 41 | @abstractmethod 42 | def start_stream(self): 43 | raise NotImplementedError 44 | 45 | @abstractmethod 46 | def stop_stream(self): 47 | raise NotImplementedError 48 | 49 | @abstractmethod 50 | def close(self): 51 | raise NotImplementedError 52 | -------------------------------------------------------------------------------- /pya/helper/__init__.py: -------------------------------------------------------------------------------- 1 | # from .helpers import audio_from_file, buf_to_float, spectrum, audio_from_file 2 | # from .helpers import normalize, device_info, find_device 3 | # from .helpers import padding, shift_bit_length 4 | from .helpers import * 5 | from .visualization import basicplot, gridplot 6 | -------------------------------------------------------------------------------- /pya/helper/backend.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from pya.backend.base import BackendBase 5 | from pya.backend.PyAudio import PyAudioBackend 6 | from pya.backend.Jupyter import JupyterBackend 7 | 8 | 9 | def get_server_info(): 10 | import re 11 | import json 12 | import requests 13 | import ipykernel 14 | import notebook.notebookapp 15 | kernel_id = re.search( 16 | "kernel-(.*).json", 17 | ipykernel.connect.get_connection_file() 18 | ).group(1) 19 | servers = notebook.notebookapp.list_running_servers() 20 | for s in servers: 21 | response = requests.get( 22 | requests.compat.urljoin(s["url"], "api/sessions"), 23 | params={"token": s.get("token", "")} 24 | ) 25 | for n in json.loads(response.text): 26 | if n["kernel"]["id"] == kernel_id: 27 | return s 28 | return None 29 | 30 | 31 | def try_pyaudio_backend(**kwargs) -> Optional["PyAudioBackend"]: 32 | try: 33 | from pya.backend.PyAudio import PyAudioBackend 34 | return PyAudioBackend(**kwargs) 35 | except ImportError: 36 | return None 37 | 38 | 39 | def try_jupyter_backend(port, **kwargs) -> Optional["JupyterBackend"]: 40 | import os 41 | from pya.backend.Jupyter import JupyterBackend 42 | server_info = get_server_info() 43 | if server_info is None or (server_info['hostname'] in ['localhost', '127.0.0.1'] and not force_webaudio): 44 | return None # use default local backend 45 | if os.environ.get('BINDER_SERVICE_HOST'): 46 | return JupyterBackend(port=port, proxy_suffix=f"/proxy/{port}", **kwargs) 47 | else: 48 | return JupyterBackend(port=port, **kwargs) 49 | 50 | 51 | def determine_backend(force_webaudio=False, port=8765, **kwargs) -> "BackendBase": 52 | """Determine a suitable Backend implementation 53 | 54 | This will first try a local pyaudio Backend unless force_webaudio is set. 55 | 56 | Parameters 57 | ---------- 58 | force_webaudio : bool, optional 59 | prefer JupyterBackend, by default False 60 | port : int, optional 61 | port for the JupyterBackend, by default 8765 62 | 63 | Returns 64 | ------- 65 | PyAudioBackend or JupyterBackend 66 | Backend instance 67 | 68 | Raises 69 | ------ 70 | RuntimeError 71 | if no Backend is available 72 | """ 73 | backend = None if force_webaudio else try_pyaudio_backend(**kwargs) 74 | if backend is None: 75 | backend = try_jupyter_backend(port=port, **kwargs) 76 | if backend is None: 77 | raise RuntimeError( 78 | "Could not find a backend. " 79 | "To use the Aserver install the 'pyaudio' or 'remote' extra." 80 | ) 81 | return backend 82 | -------------------------------------------------------------------------------- /pya/helper/codec.py: -------------------------------------------------------------------------------- 1 | # This file handles opening audio files. 2 | import wave 3 | import aifc 4 | import sunau 5 | import audioop 6 | import struct 7 | import sys 8 | import subprocess 9 | import re 10 | import time 11 | import os 12 | import threading 13 | from warnings import warn 14 | try: 15 | import queue 16 | except ImportError: 17 | import Queue as queue 18 | 19 | COMMANDS = ('ffmpeg', 'avconv') 20 | 21 | if sys.platform == "win32": 22 | PROC_FLAGS = 0x08000000 23 | else: 24 | PROC_FLAGS = 0 25 | 26 | # Produce two-byte (16-bit) output samples. 27 | TARGET_WIDTH = 2 28 | # Python 3.4 added support for 24-bit (3-byte) samples. 29 | if sys.version_info > (3, 4, 0): 30 | SUPPORTED_WIDTHS = (1, 2, 3, 4) 31 | else: 32 | SUPPORTED_WIDTHS = (1, 2, 4) 33 | 34 | 35 | class DecodeError(Exception): 36 | """The base exception class for all decoding errors raised by this 37 | package.""" 38 | 39 | 40 | class NoBackendError(DecodeError): 41 | """The file could not be decoded by any backend. Either no backends 42 | are available or each available backend failed to decode the file. 43 | """ 44 | 45 | 46 | class UnsupportedError(DecodeError): 47 | """File is not an AIFF, WAV, or Au file.""" 48 | 49 | 50 | class BitWidthError(DecodeError): 51 | """The file uses an unsupported bit width.""" 52 | 53 | 54 | class FFmpegError(DecodeError): 55 | pass 56 | 57 | 58 | class CommunicationError(FFmpegError): 59 | """Raised when the output of FFmpeg is not parseable.""" 60 | 61 | 62 | class NotInstalledError(FFmpegError): 63 | """Could not find the ffmpeg binary.""" 64 | 65 | 66 | class ReadTimeoutError(FFmpegError): 67 | """Reading from the ffmpeg command-line tool timed out.""" 68 | 69 | 70 | def byteswap(s): 71 | """Swaps the endianness of the bytesting s, which must be an array 72 | of shorts (16-bit signed integers). This is probably less efficient 73 | than it should be. 74 | """ 75 | assert len(s) % 2 == 0 76 | parts = [] 77 | for i in range(0, len(s), 2): 78 | chunk = s[i: i + 2] 79 | newchunk = struct.pack('h', chunk)) 80 | parts.append(newchunk) 81 | return b''.join(parts) 82 | 83 | 84 | class RawAudioFile(object): 85 | """An AIFF, WAV, or Au file that can be read by the Python standard 86 | library modules ``wave``, ``aifc``, and ``sunau``.""" 87 | def __init__(self, filename): 88 | self._fh = open(filename, 'rb') 89 | try: # aifc format 90 | self._file = aifc.open(self._fh) 91 | except aifc.Error: 92 | # Return to the beginning of the file to try the next reader. 93 | self._fh.seek(0) 94 | else: 95 | self._needs_byteswap = True 96 | self._check() 97 | return 98 | 99 | try: # .wav format 100 | self._file = wave.open(self._fh) 101 | except wave.Error: 102 | self._fh.seek(0) 103 | pass 104 | else: 105 | self._needs_byteswap = False 106 | self._check() 107 | return 108 | 109 | try: # sunau format. 110 | self._file = sunau.open(self._fh) 111 | except sunau.Error: 112 | self._fh.seek(0) 113 | pass 114 | else: 115 | self._needs_byteswap = True 116 | self._check() 117 | return 118 | 119 | # None of the three libraries could open the file. 120 | self._fh.close() 121 | raise UnsupportedError() 122 | 123 | def _check(self): 124 | """Check that the files' parameters allow us to decode it and 125 | raise an error otherwise. 126 | """ 127 | if self._file.getsampwidth() not in SUPPORTED_WIDTHS: 128 | self.close() 129 | raise BitWidthError() 130 | 131 | def close(self): 132 | """Close the underlying file.""" 133 | self._file.close() 134 | self._fh.close() 135 | 136 | @property 137 | def channels(self): 138 | """Number of audio channels.""" 139 | return self._file.getnchannels() 140 | 141 | @property 142 | def samplerate(self): 143 | """Sample rate in Hz.""" 144 | return self._file.getframerate() 145 | 146 | @property 147 | def duration(self): 148 | """Length of the audio in seconds (a float).""" 149 | return float(self._file.getnframes()) / self.samplerate 150 | 151 | def read_data(self, block_samples=1024): 152 | """Generates blocks of PCM data found in the file.""" 153 | old_width = self._file.getsampwidth() 154 | 155 | while True: 156 | data = self._file.readframes(block_samples) 157 | if not data: 158 | break 159 | 160 | # Make sure we have the desired bitdepth and endianness. 161 | data = audioop.lin2lin(data, old_width, TARGET_WIDTH) 162 | if self._needs_byteswap and self._file.getcomptype() != 'sowt': 163 | # Big-endian data. Swap endianness. 164 | data = byteswap(data) 165 | yield data 166 | 167 | # Context manager. 168 | def __enter__(self): 169 | return self 170 | 171 | def __exit__(self, exc_type, exc_val, exc_tb): 172 | self.close() 173 | return False 174 | 175 | # Iteration. 176 | def __iter__(self): 177 | return self.read_data() 178 | 179 | 180 | # This part is for ffmpeg read. 181 | class QueueReaderThread(threading.Thread): 182 | """A thread that consumes data from a filehandle and sends the data 183 | over a Queue.""" 184 | def __init__(self, fh, blocksize=1024, discard=False): 185 | super(QueueReaderThread, self).__init__() 186 | self.fh = fh 187 | self.blocksize = blocksize 188 | self.daemon = True 189 | self.discard = discard 190 | self.queue = None if discard else queue.Queue() 191 | 192 | def run(self): 193 | while True: 194 | data = self.fh.read(self.blocksize) 195 | if not self.discard: 196 | self.queue.put(data) 197 | if not data: 198 | break # Stream closed (EOF). 199 | 200 | 201 | def popen_multiple(commands, command_args, *args, **kwargs): 202 | """Like `subprocess.Popen`, but can try multiple commands in case 203 | some are not available. 204 | `commands` is an iterable of command names and `command_args` are 205 | the rest of the arguments that, when appended to the command name, 206 | make up the full first argument to `subprocess.Popen`. The 207 | other positional and keyword arguments are passed through. 208 | """ 209 | for i, command in enumerate(commands): 210 | cmd = [command] + command_args 211 | try: 212 | return subprocess.Popen(cmd, *args, **kwargs) 213 | except OSError: 214 | if i == len(commands) - 1: 215 | # No more commands to try. 216 | raise 217 | 218 | 219 | def ffmpeg_available(): 220 | # """Detect if the FFmpeg backend can be used on this system.""" 221 | # proc = popen_multiple( 222 | # COMMANDS, 223 | # ['-version'], 224 | # stdout=subprocess.PIPE, 225 | # stderr=subprocess.PIPE, 226 | # ) 227 | # proc.wait() 228 | # return (proc.returncode == 0) 229 | """Detect whether the FFmpeg backend can be used on this system. 230 | """ 231 | try: 232 | proc = popen_multiple( 233 | COMMANDS, 234 | ['-version'], 235 | stdout=subprocess.PIPE, 236 | stderr=subprocess.PIPE, 237 | creationflags=PROC_FLAGS, 238 | ) 239 | except OSError: 240 | return False 241 | else: 242 | proc.wait() 243 | return proc.returncode == 0 244 | 245 | 246 | # For Windows error switch management, we need a lock to keep the mode 247 | # adjustment atomic. 248 | windows_error_mode_lock = threading.Lock() 249 | 250 | 251 | class FFmpegAudioFile(object): 252 | """An audio file decoded by the ffmpeg command-line utility.""" 253 | def __init__(self, filename, block_size=4096): 254 | # On Windows, we need to disable the subprocess's crash dialog 255 | # in case it dies. Passing SEM_NOGPFAULTERRORBOX to SetErrorMode 256 | # disables this behavior. 257 | windows = sys.platform.startswith("win") 258 | # This is only for windows. 259 | if windows: 260 | windows_error_mode_lock.acquire() 261 | SEM_NOGPFAULTERRORBOX = 0x0002 262 | import ctypes 263 | # We call SetErrorMode in two steps to avoid overriding 264 | # existing error mode. 265 | previous_error_mode = \ 266 | ctypes.windll.kernel32.SetErrorMode(SEM_NOGPFAULTERRORBOX) 267 | ctypes.windll.kernel32.SetErrorMode( 268 | previous_error_mode | SEM_NOGPFAULTERRORBOX 269 | ) 270 | try: 271 | self.devnull = open(os.devnull) 272 | 273 | self.proc = popen_multiple( 274 | COMMANDS, 275 | ['-i', filename, '-f', 's16le', '-'], 276 | stdout=subprocess.PIPE, 277 | stderr=subprocess.PIPE, 278 | stdin=self.devnull, 279 | ) 280 | 281 | except OSError: 282 | raise NotInstalledError() 283 | 284 | finally: 285 | # Reset previous error mode on Windows. (We can change this 286 | # back now because the flag was inherited by the subprocess; 287 | # we don't need to keep it set in the parent process.) 288 | if windows: 289 | try: 290 | import ctypes 291 | ctypes.windll.kernel32.SetErrorMode(previous_error_mode) 292 | finally: 293 | windows_error_mode_lock.release() 294 | 295 | # Start another thread to consume the standard output of the 296 | # process, which contains raw audio data. 297 | self.stdout_reader = QueueReaderThread(self.proc.stdout, block_size) 298 | self.stdout_reader.start() 299 | 300 | # Read relevant information from stderr. 301 | self._get_info() 302 | 303 | # Start a separate thread to read the rest of the data from 304 | # stderr. This (a) avoids filling up the OS buffer and (b) 305 | # collects the error output for diagnosis. 306 | self.stderr_reader = QueueReaderThread(self.proc.stderr) 307 | self.stderr_reader.start() 308 | 309 | def read_data(self, timeout=10.0): 310 | """Read blocks of raw PCM data from the file.""" 311 | # Read from stdout in a separate thread and consume data from 312 | # the queue. 313 | start_time = time.time() 314 | while True: 315 | # Wait for data to be available or a timeout. 316 | data = None 317 | try: 318 | data = self.stdout_reader.queue.get(timeout=timeout) 319 | if data: 320 | yield data 321 | else: 322 | # End of file. 323 | break 324 | except queue.Empty: 325 | # Queue read timed out. 326 | end_time = time.time() 327 | if not data: 328 | if end_time - start_time >= timeout: 329 | # Nothing interesting has happened for a while -- 330 | # FFmpeg is probably hanging. 331 | raise ReadTimeoutError('ffmpeg output: {}'.format( 332 | ''.join(self.stderr_reader.queue.queue) 333 | )) 334 | else: 335 | start_time = end_time 336 | # Keep waiting. 337 | continue 338 | 339 | def _get_info(self): 340 | """Reads the tool's output from its stderr stream, extracts the 341 | relevant information, and parses it. 342 | """ 343 | out_parts = [] 344 | while True: 345 | line = self.proc.stderr.readline() 346 | if not line: 347 | # EOF and data not found. 348 | raise CommunicationError("stream info not found") 349 | 350 | # In Python 3, result of reading from stderr is bytes. 351 | if isinstance(line, bytes): 352 | line = line.decode('utf8', 'ignore') 353 | 354 | line = line.strip().lower() 355 | 356 | if 'no such file' in line: 357 | raise IOError('file not found') 358 | elif 'invalid data found' in line: 359 | raise UnsupportedError() 360 | elif 'duration:' in line: 361 | out_parts.append(line) 362 | elif 'audio:' in line: 363 | out_parts.append(line) 364 | self._parse_info(''.join(out_parts)) 365 | break 366 | 367 | def _parse_info(self, s): 368 | """Given relevant data from the ffmpeg output, set audio 369 | parameter fields on this object. 370 | """ 371 | # Sample rate. 372 | match = re.search(r'(\d+) hz', s) 373 | if match: 374 | self.samplerate = int(match.group(1)) 375 | else: 376 | self.samplerate = 0 377 | 378 | # Channel count. 379 | match = re.search(r'hz, ([^,]+),', s) 380 | if match: 381 | mode = match.group(1) 382 | if mode == 'stereo': 383 | self.channels = 2 384 | else: 385 | cmatch = re.match(r'(\d+)\.?(\d)?', mode) 386 | if cmatch: 387 | self.channels = sum(map(int, cmatch.group().split('.'))) 388 | else: 389 | self.channels = 1 390 | else: 391 | self.channels = 0 392 | 393 | # Duration. 394 | match = re.search( 395 | r'duration: (\d+):(\d+):(\d+).(\d)', s 396 | ) 397 | if match: 398 | durparts = list(map(int, match.groups())) 399 | duration = (durparts[0] * 60 * 60 + durparts[1] * 60 + durparts[2] + float(durparts[3]) / 10) 400 | self.duration = duration 401 | else: 402 | # No duration found. 403 | self.duration = 0 404 | 405 | def close(self): 406 | """Close the ffmpeg process used to perform the decoding.""" 407 | if hasattr(self, 'proc'): 408 | # First check the process's execution status before attempting to 409 | # kill it. This fixes an issue on Windows Subsystem for Linux where 410 | # ffmpeg closes normally on its own, but never updates 411 | # `returncode`. 412 | self.proc.poll() 413 | 414 | # Kill the process if it is still running. 415 | if self.proc.returncode is None: 416 | self.proc.kill() 417 | self.proc.wait() 418 | 419 | # Wait for the stream-reading threads to exit. (They need to 420 | # stop reading before we can close the streams.) 421 | if hasattr(self, 'stderr_reader'): 422 | self.stderr_reader.join() 423 | if hasattr(self, 'stdout_reader'): 424 | self.stdout_reader.join() 425 | 426 | # Close the stdout and stderr streams that were opened by Popen, 427 | # which should occur regardless of if the process terminated 428 | # cleanly. 429 | self.proc.stdout.close() 430 | self.proc.stderr.close() 431 | # Close the handle to os.devnull, which is opened regardless of if 432 | # a subprocess is successfully created. 433 | self.devnull.close() 434 | 435 | def __del__(self): 436 | self.close() 437 | 438 | # Iteration. 439 | def __iter__(self): 440 | return self.read_data() 441 | 442 | # Context manager. 443 | def __enter__(self): 444 | return self 445 | 446 | def __exit__(self, exc_type, exc_val, exc_tb): 447 | self.close() 448 | return False 449 | 450 | 451 | def available_backends(): 452 | """Returns a list of backends that are available on this system.""" 453 | # Standard-library WAV and AIFF readers. 454 | ab = [RawAudioFile] 455 | # Audioread also supports other backends such as coreaudio and gst. But 456 | # to simplify, we only use the standard library and ffmpeg. 457 | try: 458 | if ffmpeg_available(): # FFmpeg. 459 | ab.append(FFmpegAudioFile) 460 | except: 461 | warn("Fail to find FFMPEG backend, please refer to project Github page for installation guide. For now Mp3 is not supported.") 462 | return ab 463 | 464 | 465 | def audio_read(fp): 466 | backends = available_backends() 467 | for BackendClass in backends: 468 | try: 469 | return BackendClass(fp) 470 | except DecodeError: 471 | pass 472 | raise NoBackendError("Couldn't find a suitable backend to load the file. Most likely FFMPEG is not installed. Check github repo for installation guide.") # If all backends fails -------------------------------------------------------------------------------- /pya/helper/helpers.py: -------------------------------------------------------------------------------- 1 | # Collection of small helper functions 2 | import numpy as np 3 | from scipy.fftpack import fft 4 | from .codec import audio_read 5 | import logging 6 | import decimal 7 | import math 8 | 9 | 10 | class _error(Exception): 11 | pass 12 | 13 | 14 | def spectrum(sig, samples, channels, sr): 15 | """Return spectrum of a given signal. This method return spectrum matrix if input signal is multi-channels. 16 | 17 | Parameters 18 | ---------- 19 | sig : numpy.ndarray 20 | signal array 21 | samples : int 22 | total amount of samples 23 | channels : int 24 | signal channels 25 | sr : int 26 | sampling rate 27 | 28 | Returns 29 | --------- 30 | frq : numpy.ndarray 31 | frequencies 32 | Y : numpy.ndarray 33 | FFT of the signal. 34 | """ 35 | nrfreqs = samples // 2 + 1 36 | frq = np.linspace(0, 0.5 * sr, nrfreqs) # one sides frequency range 37 | if channels == 1: 38 | Y = fft(sig)[:nrfreqs] # / self.samples 39 | else: 40 | Y = np.array(np.zeros((nrfreqs, channels)), dtype=complex) 41 | for i in range(channels): 42 | Y[:, i] = fft(sig[:, i])[:nrfreqs] 43 | return frq, Y 44 | 45 | 46 | def normalize(d): 47 | """Return the normalized input array""" 48 | # d is a (n x dimension) np array 49 | d -= np.min(d, axis=0) 50 | d /= np.ptp(d, axis=0) 51 | return d 52 | 53 | 54 | def audio_from_file(path: str, dtype=np.float32): 55 | '''Load an audio buffer using audioread. 56 | This loads one block at a time, and then concatenates the results. 57 | ''' 58 | y = [] # audio array 59 | with audio_read(path) as input_file: 60 | sr_native = input_file.samplerate 61 | n_channels = input_file.channels 62 | s_start = 0 63 | s_end = np.inf 64 | n = 0 65 | for frame in input_file: 66 | frame = buf_to_float(frame, dtype=dtype) 67 | n_prev = n 68 | n = n + len(frame) 69 | if n_prev <= s_start <= n: 70 | # beginning is in this frame 71 | frame = frame[(s_start - n_prev):] 72 | # tack on the current frame 73 | y.append(frame) 74 | 75 | if y: 76 | y = np.concatenate(y) 77 | if n_channels > 1: 78 | y = y.reshape((-1, n_channels)) 79 | else: 80 | y = np.empty(0, dtype=dtype) 81 | sr_native = 0 82 | return y, sr_native 83 | 84 | 85 | def buf_to_float(x, n_bytes=2, dtype=np.float32): 86 | """Convert an integer buffer to floating point values. 87 | This is primarily useful when loading integer-valued wav data 88 | into numpy arrays. 89 | See Also 90 | -------- 91 | buf_to_float 92 | Parameters 93 | ---------- 94 | x : np.ndarray [dtype=int] 95 | The integer-valued data buffer 96 | n_bytes : int [1, 2, 4] 97 | The number of bytes per sample in `x` 98 | dtype : numeric type 99 | The target output type (default: 32-bit float) 100 | Returns 101 | ------- 102 | x_float : np.ndarray [dtype=float] 103 | The input data buffer cast to floating point 104 | """ 105 | # Invert the scale of the data 106 | scale = 1. / float(1 << ((8 * n_bytes) - 1)) 107 | # Construct the format string 108 | fmt = '= min_input and dev['maxOutputChannels'] >= min_output: 147 | res.append(dev) 148 | return res 149 | 150 | 151 | def padding(x, width, tail=True, constant_values=0): 152 | """Pad signal with certain width, support 1-3D tensors. 153 | Use it to add silence to a signal 154 | TODO: CHECK pad array 155 | 156 | 157 | Parameters 158 | ---------- 159 | x : np.ndarray 160 | A numpy array 161 | width : int 162 | The amount of padding. 163 | tail : bool 164 | If true pad to the tail, else pad to the start. 165 | constant_values : int or float or None 166 | The value to be padded, add None will pad nan to the array 167 | 168 | Returns 169 | ------- 170 | _ : np.ndarray 171 | Padded array 172 | """ 173 | pad = (0, width) if tail else (width, 0) 174 | if x.ndim == 1: 175 | return np.pad(x, (pad), mode='constant', constant_values=constant_values) 176 | elif x.ndim == 2: 177 | return np.pad(x, (pad, (0, 0)), mode='constant', constant_values=constant_values) 178 | elif x.ndim == 3: 179 | return np.pad(x, ((0, 0), pad, (0, 0)), mode='constant', constant_values=constant_values) 180 | else: 181 | raise AttributeError("only support ndim 1 or 2, 3. For higher please just use np.pad ") 182 | 183 | 184 | def is_pow2(val): 185 | """Check if input is a power of 2 return a bool result.""" 186 | return False if val <= 0 else math.log(val, 2).is_integer() 187 | 188 | 189 | def next_pow2(x): 190 | """Find the closest pow of 2 that is great or equal or x, 191 | based on shift_bit_length 192 | 193 | Parameters 194 | ---------- 195 | x : int 196 | A positive number 197 | 198 | Returns 199 | ------- 200 | _ : int 201 | The cloest integer that is greater or equal to input x. 202 | """ 203 | if x < 0: 204 | raise AttributeError("x needs to be positive integer.") 205 | return 1 << (x - 1).bit_length() 206 | 207 | 208 | def round_half_up(number): 209 | """Round up if >= .5""" 210 | return int(decimal.Decimal(number).quantize(decimal.Decimal('1'), rounding=decimal.ROUND_HALF_UP)) 211 | 212 | 213 | def rolling_window(a, window, step=1): 214 | shape = a.shape[:-1] + (a.shape[-1] - window + 1, window) 215 | strides = a.strides + (a.strides[-1],) 216 | return np.lib.stride_tricks.as_strided(a, shape=shape, strides=strides)[::step] 217 | 218 | 219 | def signal_to_frame(sig, n_per_frame, frame_step, window=None, stride_trick=True): 220 | """Frame a signal into overlapping frames. 221 | 222 | Parameters 223 | ---------- 224 | sig : numpy.ndarray 225 | The audio signal 226 | n_per_frame : int 227 | Number of samples each frame 228 | frame_step : int 229 | Number of samples after the start of the previous frame that the next frame should begin. 230 | window : numpy.ndarray or None 231 | A window array, e.g, 232 | stride_trick : bool 233 | Use stride trick to compute the rolling window and window multiplication faster 234 | 235 | Returns 236 | ------- 237 | _ : numpy.ndarray 238 | an array of frames. 239 | """ 240 | slen = len(sig) 241 | n_per_frame = int(round_half_up(n_per_frame)) 242 | frame_step = int(round_half_up(frame_step)) 243 | if slen <= n_per_frame: 244 | numframes = 1 245 | else: 246 | numframes = 1 + int(math.ceil((1.0 * slen - n_per_frame) / frame_step)) 247 | padlen = int((numframes - 1) * frame_step + n_per_frame) 248 | padsignal = np.concatenate((sig, np.zeros((padlen - slen,)))) # Pad zeros to signal 249 | 250 | if stride_trick: 251 | if window is not None: 252 | win = window 253 | else: 254 | win = np.ones(n_per_frame) 255 | frames = rolling_window(padsignal, window=n_per_frame, step=frame_step) 256 | else: 257 | indices = np.tile(np.arange(0, n_per_frame), (numframes, 1)) + np.tile( 258 | np.arange(0, numframes * frame_step, frame_step), (n_per_frame, 1)).T 259 | indices = np.array(indices, dtype=np.int32) 260 | frames = padsignal[indices] 261 | if window is not None: 262 | win = window 263 | else: 264 | win = np.ones(n_per_frame) 265 | win = np.tile(win, (numframes, 1)) 266 | return frames * win 267 | 268 | 269 | def magspec(frames, NFFT): 270 | """Compute the magnitude spectrum of each frame in frames. 271 | If frames is an NxD matrix, output will be Nx(NFFT/2+1). 272 | 273 | Parameters 274 | ---------- 275 | frames : numpy.ndarray 276 | The framed array, each row is a frame, can be just a single frame. 277 | NFFT : int 278 | FFT length. If NFFT > frame_len, the frames are zero_padded. 279 | 280 | Returns 281 | ------- 282 | _ : numpy.ndarray 283 | If frames is an NxD matrix, output will be Nx(NFFT/2+1). 284 | Each row will be the magnitude spectrum of the corresponding frame. 285 | """ 286 | if np.shape(frames)[1] > NFFT: 287 | logging.warning(f'frame length {np.shape(frames)[1]} is greater than FFT size {NFFT}, ' 288 | f'frame will be truncated. Increase NFFT to avoid.') 289 | complex_spec = np.fft.rfft(frames, NFFT) 290 | return np.abs(complex_spec) 291 | 292 | 293 | def powspec(frames, NFFT): 294 | """Compute the power spectrum of each frame in frames, 295 | first comeputer the magnitude spectrum 296 | 297 | Parameters 298 | ---------- 299 | frames : numpy.ndarray 300 | Framed signal, can be just a single frame. 301 | NFFT : int 302 | The FFT length to use. If NFFT > frame_len, the frames are zero-padded. 303 | 304 | Returns 305 | ------- 306 | _ : numpy array 307 | Power spectrum of the framed signal. 308 | Each row has the size of NFFT / 2 + 1 due to rfft. 309 | """ 310 | return 1.0 / NFFT * np.square(magspec(frames, NFFT)) 311 | -------------------------------------------------------------------------------- /pya/helper/visualization.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import matplotlib.pyplot as plt 3 | import math 4 | import numpy as np 5 | import matplotlib.gridspec as grd 6 | from mpl_toolkits.axes_grid1 import make_axes_locatable 7 | 8 | 9 | def basicplot(data: np.ndarray, ticks, channels, offset=0, scale=1, 10 | cn=None, ax=None, typ='plot', cmap='inferno', 11 | xlim=None, ylim=None, xlabel='', ylabel='', 12 | show_bar=False, 13 | **kwargs): 14 | """Basic version of the plot for pya, this can be directly used 15 | by Asig. Aspec/Astft/Amfcc will have different extra setting 16 | and type. 17 | 18 | Parameters 19 | ---------- 20 | data : numpy.ndarray 21 | data array 22 | channels : int 23 | number of channels 24 | axis : matplotlib.axes, optional 25 | Plot image on the matplotlib axis if it was given. 26 | Default is None, which use plt.gca() 27 | typ : str, optional 28 | Plot type. 29 | 30 | """ 31 | ax = plt.gca() or ax 32 | if channels == 1 or (offset == 0 and scale == 1): 33 | # if mono signal or you would like to stack signals together 34 | # offset is the spacing between channel, 35 | # scale is can shrink the signal just for visualization purpose. 36 | # Plot everything on top of each other. 37 | if typ == 'plot': 38 | p = ax.plot(ticks, data, **kwargs) 39 | # return p, ax 40 | elif typ == 'spectrogram': 41 | # ticks is (times, freqs) 42 | p = ax.pcolormesh(ticks[0], ticks[1], data, 43 | cmap=plt.get_cmap(cmap), **kwargs) 44 | elif typ == 'mfcc': 45 | p = ax.pcolormesh(data, cmap=plt.get_cmap(cmap), **kwargs) 46 | else: 47 | if typ == 'plot': 48 | for idx, val in enumerate(data.T): 49 | p = ax.plot(ticks, idx * offset + val * scale, **kwargs) 50 | ax.set_xlabel(xlabel) 51 | if cn: 52 | ax.text(0, (idx + 0.1) * offset, cn[idx]) 53 | elif typ == 'spectrogram': 54 | for idx in range(data.shape[1]): 55 | p = ax.pcolormesh(ticks[0], idx * offset + scale * ticks[1], 56 | data[:, idx, :], cmap=plt.get_cmap(cmap), 57 | **kwargs) 58 | if cn: 59 | ax.text(0, (idx + 0.1) * offset, cn[idx]) 60 | ax.set_yticklabels([]) 61 | ax.set_xlabel(xlabel) 62 | if xlim: 63 | ax.set_xlim([xlim[0], xlim[1]]) 64 | if ylim: 65 | ax.set_ylim([ylim[0], ylim[1]]) 66 | # Colorbar 67 | if show_bar: 68 | divider = make_axes_locatable(ax) 69 | cax = divider.append_axes('right', size="2%", pad=0.03) 70 | _ = plt.colorbar(p, cax=cax) # Add 71 | return p, ax 72 | 73 | 74 | def gridplot(pya_objects, colwrap=1, cbar_ratio=0.04, figsize=None): 75 | """Create a grid plot of pya objects which have plot() methods, 76 | i.e. Asig, Aspec, Astft, Amfcc. 77 | It takes a list of pya_objects and plot each object into a grid. 78 | You can mix different types of plots 79 | together. 80 | 81 | Examples 82 | -------- 83 | # plot all 4 different pya objects in 1 column, 84 | amfcc and astft use pcolormesh so colorbar will 85 | # be displayed as well 86 | gridplot([asig, amfcc, aspec, astft], colwrap=2, 87 | cbar_ratio=0.08, figsize=[10, 10]); 88 | 89 | Parameters 90 | ---------- 91 | pya_objects : iterable object 92 | A list of pya objects with the plot() method. 93 | colwrap : int, optional 94 | Wrap column at position. 95 | Can be considered as the column size. Default is 1, meaning 1 column. 96 | cbar_ratio : float, optional 97 | For each column create another column reserved for the colorbar. 98 | This is the ratio of the width relative to the plot. 99 | 0.04 means 4% of the width of the data plot. 100 | figsize : tuple, optional 101 | width, height of the entire image in inches. Default size is (6.4, 4.8) 102 | 103 | Returns 104 | ------- 105 | fig : plt.figure() 106 | The plt.figure() object 107 | """ 108 | from .. import Asig, Amfcc, Astft, Aspec 109 | nplots = len(pya_objects) 110 | 111 | if colwrap > nplots: 112 | ncol = colwrap 113 | elif colwrap < 1: 114 | raise ValueError("col_wrap needs to an integer > 0") 115 | else: 116 | ncol = colwrap 117 | 118 | nrow = math.ceil(nplots / ncol) 119 | ncol = ncol * 2 # Double the col for colorbars. 120 | 121 | even_weight = 100 122 | odd_weight = even_weight * cbar_ratio 123 | wratio = [] # Aspect ratio of width 124 | for i in range(ncol): 125 | wratio.append(odd_weight) if i % 2 else wratio.append(even_weight) 126 | 127 | fig = plt.figure(figsize=figsize, constrained_layout=True) 128 | grid = grd.GridSpec(nrow, ncol, 129 | figure=fig, width_ratios=wratio, wspace=0.01) 130 | 131 | total_idx = ncol * nrow 132 | for i in range(total_idx): 133 | if not i % 2: # The data plot is always at even index. 134 | idx = i // 2 # Index in the pya_objects list 135 | if idx < nplots: 136 | ax = plt.subplot(grid[i]) 137 | # Title is object type + label 138 | title = pya_objects[idx].__repr__().split('(')[0] + ': ' + pya_objects[idx].label 139 | # Truncate if str too long 140 | title = (title[:30] + "..." if len(title) > 30 else title) 141 | ax.set_title(title) 142 | if isinstance(pya_objects[idx], Asig): 143 | pya_objects[idx].plot(ax=ax) 144 | elif isinstance(pya_objects[idx], Aspec): 145 | pya_objects[idx].plot(ax=ax) 146 | elif isinstance(pya_objects[idx], Astft): 147 | pya_objects[idx].plot(show_bar=False, ax=ax) 148 | next_ax = plt.subplot(grid[i + 1]) 149 | _ = plt.colorbar(pya_objects[idx].im, cax=next_ax) 150 | elif isinstance(pya_objects[idx], Amfcc): 151 | pya_objects[idx].plot(show_bar=False, ax=ax) 152 | next_ax = plt.subplot(grid[i + 1]) 153 | _ = plt.colorbar(pya_objects[idx].im, cax=next_ax) 154 | return fig 155 | -------------------------------------------------------------------------------- /pya/ugen.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy import signal 3 | from typing import Optional, Union 4 | 5 | from . import Asig 6 | from .helper import normalize 7 | 8 | 9 | def get_num_of_rows(dur: Optional[float], n_rows: Optional[int], sr: int): 10 | """Return total number of samples. If dur is set, return dur*sr, if num_samples is set, return num_samples, 11 | if both set, raise an AttributeError. Only use one of the two. 12 | """ 13 | if dur and n_rows is None: 14 | return int(dur * sr) 15 | elif dur is None and n_rows is None: 16 | return int(sr) 17 | elif n_rows and dur is None: 18 | return int(n_rows) 19 | else: 20 | raise AttributeError("Only use either dur or n_rows to specify the number of rows of the signal.") 21 | 22 | 23 | class Ugen(Asig): 24 | """Unit Generator for to create Asig with predefined signal 25 | 26 | Currently avaiable: 27 | sine, cos, square, sawtooth, noise 28 | 29 | Examples 30 | -------- 31 | Create common waveform in Asig. 32 | 33 | >>> from pya import Ugen 34 | >>> # Create a sine wave of 440Hz at 44100Hz sr for 2 seconds. Same for cos() 35 | >>> sine = Ugen().sine(freq=440, amp=0.8, dur=2.,label="sine") 36 | >>> # Create a square wave of 25Hz, 2000 samples at 100 sr, stereo. 37 | >>> sq = Ugen().square(freq=25, n_rows=2000, sr=100, channels=2, cn=['l', 'r']) 38 | >>> # Make a white noise, another option is 'pink', at 44100Hz for 1second. 39 | >>> noi = Ugen().noise(type='white') 40 | """ 41 | def __init__(self): 42 | pass 43 | 44 | def sine(self, freq: Union[int, float] = 440, amp: Union[int, float] = 1.0, 45 | dur: Optional[float] = None, n_rows: Optional[int] = None, 46 | sr: int = 44100, channels: int = 1, cn: Optional[list] = None, label: str = "sine"): 47 | """Generate Sine signal Asig object. 48 | 49 | Parameters 50 | ---------- 51 | freq : int, float 52 | signal frequency (Default value = 440) 53 | amp : int, float 54 | signal amplitude (Default value = 1.0) 55 | dur : float 56 | duration in second. dur and n_rows only use one of the two. (Default value = 1.0) 57 | n_rows : int 58 | number of rows (samples). dur and n_rows only use one of the two(Default value = None) 59 | sr : int 60 | sampling rate (Default value = 44100) 61 | channels : int 62 | number of channels (Default value = 1) 63 | cn : list of string 64 | channel names as a list. The size needs to match the number of channels (Default value = None) 65 | label : string 66 | identifier of the object (Default value = "sine") 67 | 68 | Returns 69 | ------- 70 | Asig 71 | """ 72 | length = get_num_of_rows(dur, n_rows, sr) 73 | sig = amp * np.sin(2 * np.pi * freq * np.linspace(0, length / sr, length, endpoint=False)) 74 | if channels > 1: 75 | sig = np.repeat(sig, channels) 76 | sig = sig.reshape((length, channels)) 77 | return Asig(sig, sr=sr, label=label, channels=channels, cn=cn) 78 | 79 | def cos(self, freq: Union[int, float] = 440, amp: Union[int, float] = 1.0, 80 | dur: Optional[float] = None, n_rows: Optional[int] = None, 81 | sr: int = 44100, channels: int = 1, cn: Optional[list] = None, label: str = "cosine"): 82 | """Generate Cosine signal Asig object. 83 | 84 | Parameters 85 | ---------- 86 | freq : int, float 87 | signal frequency (Default value = 440) 88 | amp : int, float 89 | signal amplitude (Default value = 1.0) 90 | dur : int, float 91 | duration in second. dur and num_rows only use one of the two. (Default value = 1.0) 92 | n_rows : int 93 | number of rows (samples). dur and num_rows only use one of the two(Default value = None) 94 | sr : int 95 | sampling rate (Default value = 44100) 96 | channels : int 97 | number of channels (Default value = 1) 98 | cn : list of string 99 | channel names as a list. The size needs to match the number of channels (Default value = None) 100 | label : string 101 | identifier of the object (Default value = "cosine") 102 | 103 | Returns 104 | ------- 105 | Asig 106 | """ 107 | length = get_num_of_rows(dur, n_rows, sr) 108 | sig = amp * np.cos(2 * np.pi * freq * np.linspace(0, length / sr, length, endpoint=False)) 109 | if channels > 1: 110 | sig = np.repeat(sig, channels) 111 | sig = sig.reshape((length, channels)) 112 | return Asig(sig, sr=sr, label=label, channels=channels, cn=cn) 113 | 114 | def square(self, freq=440, amp=1.0, dur=None, n_rows=None, 115 | duty=0.5, sr=44100, sample_shift=0.5, 116 | channels=1, cn=None, label="square"): 117 | """Generate square wave signal Asig object. 118 | 119 | Parameters 120 | ---------- 121 | freq : int, float 122 | signal frequency (Default value = 440) 123 | amp : int, float 124 | signal amplitude (Default value = 1.0) 125 | dur : int, float 126 | duration in second. dur and num_rows only use one of the two. (Default value = 1.0) 127 | num_rows : int 128 | number of row (samples). dur and num_rows only use one of the two(Default value = None) 129 | duty : float 130 | duty cycle (Default value = 0.4) 131 | sr : int 132 | sampling rate (Default value = 44100) 133 | channels : int 134 | number of channels (Default value = 1) 135 | cn : list of string 136 | channel names as a list. The size needs to match the number of channels (Default value = None) 137 | label : string 138 | identifier of the object (Default value = "square") 139 | 140 | Returns 141 | ------- 142 | Asig 143 | """ 144 | length = get_num_of_rows(dur, n_rows, sr) 145 | sig = amp * signal.square( 146 | 2 * np.pi * freq * ((sample_shift / length) + np.linspace(0, length / sr, length, endpoint=False)), 147 | duty=duty) 148 | if channels > 1: 149 | sig = np.repeat(sig, channels) 150 | sig = sig.reshape((length, channels)) 151 | return Asig(sig, sr=sr, label=label, channels=channels, cn=cn) 152 | 153 | def sawtooth(self, freq=440, amp=1.0, dur=None, n_rows=None, 154 | width=1., sr=44100, channels=1, cn=None, label="sawtooth"): 155 | """Generate sawtooth wave signal Asig object. 156 | 157 | Parameters 158 | ---------- 159 | freq : int, float 160 | signal frequency (Default value = 440) 161 | amp : int, float 162 | signal amplitude (Default value = 1.0) 163 | dur : int, float 164 | duration in second. dur and num_rows only use one of the two. (Default value = 1.0) 165 | num_rows : int 166 | number of rows (samples). dur and num_rows only use one of the two(Default value = None) 167 | width : float 168 | tooth width (Default value = 1.0) 169 | sr : int 170 | sampling rate (Default value = 44100) 171 | channels : int 172 | number of channels (Default value = 1) 173 | cn : list of string 174 | channel names as a list. The size needs to match the number of channels (Default value = None) 175 | label : string 176 | identifier of the object (Default value = "sawtooth") 177 | 178 | Returns 179 | ------- 180 | Asig 181 | """ 182 | length = get_num_of_rows(dur, n_rows, sr) 183 | sig = amp * signal.sawtooth(2 * np.pi * freq * np.linspace(0, length / sr, length, endpoint=False), 184 | width=width) 185 | if channels > 1: 186 | sig = np.repeat(sig, channels) 187 | sig = sig.reshape((length, channels)) 188 | return Asig(sig, sr=sr, label=label, channels=channels, cn=cn) 189 | 190 | def noise(self, type="white", amp=1.0, dur=None, n_rows=None, 191 | sr=44100, channels=1, cn=None, label="noise"): 192 | """Generate noise signal Asig object. 193 | 194 | Parameters 195 | ---------- 196 | type : string 197 | type of noise, currently available: 'white' and 'pink' (Default value = 'white') 198 | amp : int, float 199 | signal amplitude (Default value = 1.0) 200 | dur : int, float 201 | duration in second. dur and num_rows only use one of the two. (Default value = 1.0) 202 | num_rows : int 203 | number of rows (samples). dur and num_rows only use one of the two(Default value = None) 204 | sr : int 205 | sampling rate (Default value = 44100) 206 | channels : int 207 | number of channels (Default value = 1) 208 | cn : list of string 209 | channel names as a list. The size needs to match the number of channels (Default value = None) 210 | label : string 211 | identifier of the object (Default value = "square") 212 | 213 | Returns 214 | ------- 215 | Asig 216 | """ 217 | length = get_num_of_rows(dur, n_rows, sr) 218 | # Question is that will be that be too slow.] 219 | if type == "white" or type == "white_noise": 220 | sig = (np.random.rand(length) - 0.5) * 2. * amp 221 | 222 | elif type == "pink" or type == "pink_noise": 223 | # Based on Paul Kellet's method 224 | b0, b1, b2, b3, b4, b5, b6 = 0, 0, 0, 0, 0, 0, 0 225 | sig = [] 226 | for i in range(length): 227 | white = np.random.random() * 1.98 - 0.99 228 | b0 = 0.99886 * b0 + white * 0.0555179 229 | b1 = 0.99332 * b1 + white * 0.0750759 230 | b2 = 0.96900 * b2 + white * 0.1538520 231 | b3 = 0.86650 * b3 + white * 0.3104856 232 | b4 = 0.55000 * b4 + white * 0.5329522 233 | b5 = -0.7616 * b5 - white * 0.0168980 234 | sig.append(b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) 235 | b6 = white * 0.115926 236 | sig = normalize(sig) * amp 237 | if channels > 1: 238 | sig = np.repeat(sig, channels) 239 | sig = sig.reshape((length, channels)) 240 | return Asig(sig, sr=sr, channels=channels, cn=cn, label=label) 241 | -------------------------------------------------------------------------------- /pya/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.2" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyamapping 2 | scipy>=1.7.3 3 | matplotlib>=3.5.3 4 | -------------------------------------------------------------------------------- /requirements_doc.txt: -------------------------------------------------------------------------------- 1 | Sphinx==5.3.0 2 | sphinx-autoapi==2.0.0 3 | sphinx-rtd-theme==1.1.1 4 | sphinx-mdinclude 5 | -------------------------------------------------------------------------------- /requirements_pyaudio.txt: -------------------------------------------------------------------------------- 1 | pyaudio>=0.2.12; python_version >= '3.10' 2 | pyaudio>=0.2.11; python_version < '3.10' 3 | -------------------------------------------------------------------------------- /requirements_remote.txt: -------------------------------------------------------------------------------- 1 | notebook 2 | websockets 3 | ipywidgets 4 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pycodestyle 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [check-manifest] 5 | ignore = 6 | .travis.yml 7 | pya/notes.md 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import os 3 | import codecs 4 | from os.path import join 5 | 6 | project_root = os.path.dirname(os.path.abspath(__file__)) 7 | 8 | version = {} 9 | REQUIRED_EXTRAS = {} 10 | with open(join(project_root, 'pya/version.py')) as read_file: 11 | exec(read_file.read(), version) 12 | 13 | with open(join(project_root, 'requirements.txt')) as read_file: 14 | REQUIRED = read_file.read().splitlines() 15 | 16 | with open(join(project_root, 'requirements_remote.txt')) as read_file: 17 | REQUIRED_EXTRAS['remote'] = read_file.read().splitlines() 18 | 19 | with open(join(project_root, 'requirements_pyaudio.txt')) as read_file: 20 | REQUIRED_EXTRAS['pyaudio'] = read_file.read().splitlines() 21 | 22 | with open(join(project_root, 'requirements_test.txt')) as read_file: 23 | REQUIRED_TEST = read_file.read().splitlines() 24 | 25 | with codecs.open(join(project_root, 'README.md'), 'r', 'utf-8') as f: 26 | long_description = ''.join(f.readlines()) 27 | 28 | setup( 29 | name='pya', 30 | version=version['__version__'], 31 | description='Python audio coding classes - for dsp and sonification', 32 | long_description=long_description, 33 | long_description_content_type="text/markdown", 34 | license='MIT', 35 | packages=find_packages(exclude=["tests"]), 36 | install_requires=REQUIRED, 37 | extras_require=REQUIRED_EXTRAS, 38 | tests_require=REQUIRED_TEST, 39 | author='Thomas Hermann', 40 | author_email='thermann@techfak.uni-bielefeld.de', 41 | keywords=['sonification, sound synthesis'], 42 | url='https://github.com/interactive-sonification/pya', 43 | classifiers=[ 44 | "Programming Language :: Python :: 3", 45 | "Development Status :: 4 - Beta", 46 | "License :: OSI Approved :: MIT License", 47 | "Operating System :: OS Independent", 48 | "Topic :: Multimedia :: Sound/Audio", 49 | "Topic :: Multimedia :: Sound/Audio :: Analysis", 50 | "Topic :: Multimedia :: Sound/Audio :: Sound Synthesis" 51 | ], 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/interactive-sonification/pya/2ec364cc909bbd9e2d26c4fc389bb26a327e60d6/tests/__init__.py -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import time 3 | from typing import Callable 4 | 5 | 6 | def wait(condition: Callable[[], bool], seconds: float = 10, interval: float = 0.1): 7 | start = time.time() 8 | while time.time() - start <= seconds: 9 | if condition() is True: 10 | return True 11 | time.sleep(interval) 12 | return False 13 | 14 | 15 | def check_for_input() -> bool: 16 | with contextlib.suppress(ImportError, OSError): 17 | import pyaudio 18 | pyaudio.PyAudio().get_default_input_device_info() 19 | return True 20 | return False 21 | 22 | 23 | def check_for_output() -> bool: 24 | with contextlib.suppress(ImportError, OSError): 25 | import pyaudio 26 | pyaudio.PyAudio().get_default_output_device_info() 27 | return True 28 | return False 29 | -------------------------------------------------------------------------------- /tests/test_amfcc.py: -------------------------------------------------------------------------------- 1 | # from pya import Amfcc 2 | from pya import Asig, Ugen, Amfcc 3 | from unittest import TestCase 4 | import numpy as np 5 | import warnings 6 | 7 | 8 | class TestAmfcc(TestCase): 9 | def setUp(self): 10 | self.test_asig = Ugen().square(freq=20, sr=8000) 11 | self.test_sig = self.test_asig.sig 12 | 13 | def tearDown(self): 14 | pass 15 | 16 | def test_construct(self): 17 | # If x is asig, it will ignore sr but use x.sr instead. 18 | amfcc = Amfcc(self.test_asig, sr=45687) 19 | self.assertEqual(amfcc.sr, 8000, msg='sr does not match.') 20 | 21 | # if x is ndarray and sr is not given 22 | with self.assertRaises(AttributeError): 23 | _ = Amfcc(self.test_sig) 24 | 25 | # x is ndarray and sr is given. 26 | amfcc = Amfcc(self.test_sig, sr=1000) 27 | self.assertEqual(amfcc.sr, 1000, msg="sr does not match.") 28 | 29 | # Unsupported input type 30 | with self.assertRaises(TypeError): 31 | _ = Amfcc(x="String") 32 | 33 | def test_get_attributes(self): 34 | amfcc = Amfcc(self.test_asig) 35 | self.assertTrue(amfcc.timestamp is not None) 36 | self.assertTrue(amfcc.features is not None) 37 | 38 | def test_hopsize_greater_than_npframe(self): 39 | with warnings.catch_warnings(record=True): 40 | amfcc = Amfcc(self.test_asig, hopsize=100, n_per_frame=50) 41 | 42 | def test_nfft_not_pow2(self): 43 | with warnings.catch_warnings(record=True): 44 | amfcc = Amfcc(self.test_asig, nfft=23) 45 | 46 | def test_nowindowing(self): 47 | amfcc = Amfcc(self.test_asig, window=False) 48 | result = np.ones((amfcc.n_per_frame,)) 49 | self.assertTrue(np.array_equal(result, amfcc.window)) 50 | 51 | def test_preemphasis(self): 52 | self.assertTrue(np.array_equal(np.array([0., 1., 1.5, 2., 2.5]), 53 | Amfcc.preemphasis(np.arange(5), coeff=0.5))) 54 | 55 | def test_melfb(self): 56 | fb = Amfcc.mel_filterbanks(8000) # Using default 57 | self.assertEqual(fb.shape, (26, 257)) # nfilters, NFFT // 2 + 1 58 | 59 | def test_lifter(self): 60 | fake_cepstra = np.ones((20, 13)) 61 | lifted_ceps = Amfcc.lifter(fake_cepstra, L=22) 62 | self.assertEqual(fake_cepstra.shape, lifted_ceps.shape) 63 | # if L negative, no lifting applied 64 | lifted_ceps = Amfcc.lifter(fake_cepstra, L=-3) 65 | self.assertTrue(np.array_equal(lifted_ceps, fake_cepstra)) 66 | 67 | def test_plot_with_multichannel(self): 68 | asig = Ugen().sine(channels=2) 69 | amfcc = asig.to_mfcc() 70 | with warnings.catch_warnings(record=True): 71 | amfcc.plot() -------------------------------------------------------------------------------- /tests/test_arecorder.py: -------------------------------------------------------------------------------- 1 | # Test arecorder class. 2 | import time 3 | from pya import Arecorder, Aserver, find_device 4 | from unittest import TestCase, mock 5 | import pytest 6 | 7 | try: 8 | import pyaudio 9 | has_pyaudio = True 10 | except ImportError: 11 | has_pyaudio = False 12 | 13 | 14 | FAKE_INPUT = {'index': 0, 15 | 'structVersion': 2, 16 | 'name': 'Mock Input', 17 | 'hostApi': 0, 18 | 'maxInputChannels': 1, 19 | 'maxOutputChannels': 0, 20 | 'defaultLowInputLatency': 0.04852607709750567, 21 | 'defaultLowOutputLatency': 0.01, 22 | 'defaultHighInputLatency': 0.05868480725623583, 23 | 'defaultHighOutputLatency': 0.1, 24 | 'defaultSampleRate': 44100.0} 25 | 26 | FAKE_OUTPUT = {'index': 1, 27 | 'structVersion': 2, 28 | 'name': 'Mock Output', 29 | 'hostApi': 0, 30 | 'maxInputChannels': 2, 31 | 'maxOutputChannels': 0, 32 | 'defaultLowInputLatency': 0.01, 33 | 'defaultLowOutputLatency': 0.02, 34 | 'defaultHighInputLatency': 0.03, 35 | 'defaultHighOutputLatency': 0.04, 36 | 'defaultSampleRate': 44100.0} 37 | 38 | FAKE_AUDIO_INTERFACE = {'index': 2, 39 | 'structVersion': 2, 40 | 'name': 'Mock Audio Interface', 41 | 'hostApi': 0, 42 | 'maxInputChannels': 14, 43 | 'maxOutputChannels': 14, 44 | 'defaultLowInputLatency': 0.01, 45 | 'defaultLowOutputLatency': 0.02, 46 | 'defaultHighInputLatency': 0.03, 47 | 'defaultHighOutputLatency': 0.04, 48 | 'defaultSampleRate': 48000.0} 49 | 50 | 51 | class MockRecorder(mock.MagicMock): 52 | def get_device_count(self): 53 | return 2 54 | 55 | def get_device_info_by_index(self, arg): 56 | if arg == 0: 57 | return FAKE_INPUT 58 | elif arg == 1: 59 | return FAKE_OUTPUT 60 | elif arg == 2: 61 | return FAKE_AUDIO_INTERFACE 62 | else: 63 | raise AttributeError("Invalid device index.") 64 | 65 | def get_default_input_device_info(self): 66 | return FAKE_INPUT 67 | 68 | def get_default_output_device_info(self): 69 | return FAKE_OUTPUT 70 | 71 | # def open(self, *args, **kwargs): 72 | 73 | 74 | class TestArecorderBase(TestCase): 75 | __test__ = False 76 | backend = None 77 | max_inputs = backend.dummy_devices[0]['maxInputChannels'] if backend else 0 78 | 79 | @pytest.mark.xfail(reason="Test may get affected by PortAudio bug or potential unsuitable audio device.") 80 | def test_boot(self): 81 | ar = Arecorder(backend=self.backend).boot() 82 | self.assertTrue(ar.is_active) 83 | ar.quit() 84 | self.assertFalse(ar.is_active) 85 | 86 | @pytest.mark.xfail(reason="Test may get affected by PortAudio bug or potential unsuitable audio device.") 87 | def test_arecorder(self): 88 | ar = Arecorder(channels=1, backend=None).boot() 89 | self.assertEqual(ar.sr, 44100) 90 | ar.record() 91 | time.sleep(1.) 92 | ar.pause() 93 | time.sleep(0.2) 94 | ar.record() 95 | time.sleep(1.) 96 | ar.stop() 97 | asig = ar.recordings 98 | self.assertIsInstance(asig, list) 99 | self.assertEqual(asig[-1].sr, 44100) 100 | ar.recordings.clear() 101 | ar.quit() 102 | 103 | @pytest.mark.xfail(reason="Test may get affected by PortAudio bug or potential unsuitable audio device.") 104 | def test_combined_inout(self): 105 | # test if two streams can be opened on the same device 106 | # can only be tested when a device with in- and output capabilities is available 107 | devices = find_device(min_input=1, min_output=1) 108 | if devices: 109 | # set the buffer size low to provoke racing condition 110 | # observed in https://github.com/interactive-sonification/pya/issues/23 111 | # the occurrence of this bug depends on the machine load and will only appear when two streams 112 | # are initialized back-to-back 113 | bs = 128 114 | d = devices[0] # we only need to test one device, we take the first one 115 | recorder = Arecorder(device=d['index'], bs=bs) 116 | player = Aserver(device=d['index'], bs=bs) 117 | player.boot() 118 | recorder.boot() # initialized record and boot sequentially to provoke racing condition 119 | recorder.record() 120 | time.sleep(1.) 121 | recorder.stop() 122 | player.quit() 123 | self.assertEqual(len(recorder.recordings), 1) # we should have one Asig recorded 124 | self.assertGreater(recorder.recordings[0].sig.shape[0], 10 * bs, 125 | "Recording length is too short, < 10buffers") 126 | recorder.quit() 127 | 128 | @pytest.mark.xfail(reason="Test may get affected by PortAudio bug or potential unsuitable audio device.") 129 | def test_custom_channels(self): 130 | s = Arecorder(channels=self.max_inputs, backend=self.backend) 131 | s.boot() 132 | self.assertTrue(s.is_active) 133 | s.quit() 134 | self.assertFalse(s.is_active) 135 | 136 | @pytest.mark.xfail(reason="Test may get affected by PortAudio bug or potential unsuitable audio device.") 137 | def test_invalid_channels(self): 138 | """Raise an exception if booting with channels greater than max channels of the device. Dummy has 10""" 139 | if self.backend: 140 | s = Arecorder(channels=self.max_inputs + 1, backend=self.backend) 141 | with self.assertRaises(OSError): 142 | s.boot() 143 | else: 144 | s = Arecorder(channels=-1, backend=self.backend) 145 | with self.assertRaises(ValueError): 146 | s.boot() 147 | 148 | @pytest.mark.xfail(reason="Some devices may not have inputs") 149 | def test_default_channels(self): 150 | if self.backend: 151 | s = Arecorder(backend=self.backend) 152 | self.assertEqual(s.channels, self.backend.dummy_devices[0]['maxInputChannels']) 153 | else: 154 | s = Arecorder() 155 | self.assertGreater(s.channels, 0, "No input channel found") 156 | 157 | 158 | class TestArecorder(TestArecorderBase): 159 | __test__ = True 160 | 161 | 162 | class TestMockArecorder(TestCase): 163 | 164 | @pytest.mark.skipif(not has_pyaudio, reason="requires pyaudio to be installed") 165 | def test_mock_arecorder(self): 166 | mock_recorder = MockRecorder() 167 | with mock.patch('pyaudio.PyAudio', return_value=mock_recorder): 168 | ar = Arecorder() 169 | self.assertEqual( 170 | "Mock Input", 171 | ar.backend.get_default_input_device_info()['name']) 172 | ar.boot() 173 | self.assertTrue(mock_recorder.open.called) 174 | ar.record() 175 | # time.sleep(2) 176 | ar.pause() 177 | ar.record() 178 | ar.recordings.clear() 179 | self.assertEqual(0, len(ar.recordings)) 180 | # ar.stop() # Dont know how to mock the stop. 181 | # TODO How to mock a result. 182 | 183 | # Mock multiple input devices. 184 | ar.set_device(2, reboot=True) # Set to multiple device 185 | self.assertEqual(ar.max_in_chn, 14) 186 | -------------------------------------------------------------------------------- /tests/test_aserver.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import * 3 | import numpy as np 4 | import time 5 | 6 | 7 | class TestAserver(TestCase): 8 | 9 | def setUp(self) -> None: 10 | self.backend = DummyBackend() 11 | self.sig = np.sin(2 * np.pi * 440 * np.linspace(0, 1, 44100)) 12 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 13 | self.max_channels = self.backend.dummy_devices[0]['maxOutputChannels'] 14 | 15 | def test_default_server(self): 16 | Aserver.startup_default_server(backend=self.backend, bs=512, channels=4) 17 | s = Aserver.default 18 | self.assertEqual(s, Aserver.default) 19 | self.asine.play() 20 | time.sleep(0.5) 21 | s.stop() 22 | self.assertGreater(len(s.stream.samples_out), 0) 23 | sample = s.stream.samples_out[0] 24 | self.assertEqual(sample.shape[0], 512) 25 | self.assertEqual(sample.shape[1], 4) 26 | self.assertAlmostEqual(np.max(sample), 1, places=2) 27 | Aserver.shutdown_default_server() 28 | self.assertIsNone(s.stream) 29 | 30 | def test_custom_channels(self): 31 | """Server should boot with a valid channel""" 32 | for ch in [1, 5, 7, 10]: 33 | s = Aserver(device=0, channels=ch, backend=self.backend) 34 | s.boot() 35 | self.assertTrue(s.is_active) 36 | s.stop() 37 | s.quit() 38 | self.assertFalse(s.is_active) 39 | 40 | def test_invalid_channels(self): 41 | """Raise an exception if booting with channels greater than max channels of the device. Dummy has 10""" 42 | ch = 100 43 | s = Aserver(device=0, channels=ch, backend=self.backend) 44 | with self.assertRaises(OSError): 45 | s.boot() 46 | 47 | def test_default_channels(self): 48 | s = Aserver(device=0, backend=self.backend) 49 | self.assertEqual(s.channels, self.max_channels) 50 | 51 | def test_play_float(self): 52 | s = Aserver(backend=self.backend) 53 | s.boot() 54 | self.asine.play(server=s) 55 | time.sleep(0.5) 56 | s.stop() 57 | self.assertGreater(len(s.stream.samples_out), 0) 58 | sample = s.stream.samples_out[0] 59 | self.assertEqual(sample.shape[0], s.bs) 60 | self.assertEqual(sample.shape[1], s.channels) 61 | self.assertAlmostEqual(np.max(sample), 1, places=2) 62 | s.quit() 63 | 64 | def test_play_in_context_manager(self): 65 | with Aserver(backend=self.backend) as s: 66 | self.asine.play(server=s) 67 | time.sleep(0.5) 68 | sample = s.stream.samples_out[0] 69 | self.assertEqual(sample.shape[0], s.bs) 70 | self.assertEqual(sample.shape[1], s.channels) 71 | self.assertAlmostEqual(np.max(sample), 1, places=2) 72 | 73 | def test_none_blocking_play(self): 74 | t0 = time.time() 75 | with Aserver(backend=self.backend) as s: 76 | self.asine.play(server=s) 77 | self.asine.play(server=s) 78 | self.asine.play(server=s) 79 | dur = time.time() - t0 80 | self.assertLess(dur, self.asine.dur) 81 | 82 | def test_blocking_play(self): 83 | t0 = time.time() 84 | with Aserver(backend=self.backend) as s: 85 | self.asine.play(server=s, block=True) 86 | self.asine.play(server=s, block=True) 87 | self.asine.play(server=s, block=True) 88 | delta = time.time() - t0 - self.asine.dur * 3 89 | # plus a few hundred ms of aserver boot and stop time 90 | is_dur_reasonable = delta > 0 and delta < 1.0 91 | self.assertTrue(is_dur_reasonable) 92 | 93 | def test_repr(self): 94 | s = Aserver(backend=self.backend) 95 | s.boot() 96 | print(s) 97 | s.quit() 98 | 99 | def test_get_devices(self): 100 | s = Aserver(backend=self.backend) 101 | d_in, d_out = s.get_devices(verbose=True) 102 | self.assertListEqual(d_in, d_out) 103 | self.assertListEqual(d_in, self.backend.dummy_devices) 104 | 105 | def test_boot_twice(self): 106 | s = Aserver(backend=self.backend) 107 | s.boot() 108 | self.assertEqual(s.boot(), -1) 109 | s.quit() 110 | 111 | def test_quit_not_booted(self): 112 | s = Aserver(backend=self.backend) 113 | self.assertEqual(s.quit(), -1) 114 | 115 | def test_incompatible_backend(self): 116 | s = Aserver(backend=self.backend) 117 | sig = np.sin(2 * np.pi * 440 * np.linspace(0, 1, 44100) * np.iinfo(np.int16).max).astype(np.int16) 118 | asine = Asig(sig, sr=44100) 119 | s.boot() 120 | asine.play(server=s) 121 | s.quit() 122 | 123 | -------------------------------------------------------------------------------- /tests/test_asig.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from unittest.mock import patch 3 | from pya import Asig 4 | import numpy as np 5 | from math import inf 6 | import os 7 | 8 | 9 | class TestAsig(TestCase): 10 | """Unit Tests for Asig""" 11 | def setUp(self): 12 | self.sig = np.sin(2 * np.pi * 100 * np.linspace(0, 1, 44100)) 13 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 14 | self.asineWithName = Asig(self.sig, sr=44100, label="test_sine", cn=['sine']) 15 | self.sig2ch = np.repeat(self.sig, 2).reshape((44100, 2)) 16 | self.astereo = Asig(self.sig2ch, sr=44100, label="sterep", cn=['l', 'r']) 17 | self.sig16ch = np.repeat(self.sig, 16).reshape((44100, 16)) 18 | self.asine16ch = Asig(self.sig16ch, sr=44100, label="test_sine_16ch") 19 | self.asigconst = Asig(1.0, sr=100, label="constant signal", cn=['0']) + 0.5 20 | 21 | def tearDown(self): 22 | pass 23 | 24 | def test_asig_constructor(self): 25 | # Integer constructor 26 | asig = Asig(1000) 27 | asig = Asig(1000, channels=3) 28 | self.assertEqual(asig.samples, 1000) 29 | self.assertEqual(asig.channels, 3) 30 | 31 | def test_asig_plot(self): 32 | self.asine.plot() 33 | self.astereo.plot(offset=1., scale=0.5) 34 | 35 | def test_duration(self): 36 | self.assertEqual(self.asine.get_duration(), 1.) 37 | get_time = self.asine.get_times() 38 | self.assertTrue(np.array_equal(np.linspace(0, 39 | (self.asine.samples - 1) / self.asine.sr, 40 | self.asine.samples), self.asine.get_times())) 41 | 42 | def test_dur_property(self): 43 | self.assertEqual(self.asine.dur, 1.) 44 | 45 | def test_fader(self): 46 | result = self.asine.fade_in(dur=0.2) 47 | self.assertIsInstance(result, Asig) 48 | 49 | result = self.asine.fade_out(dur=0.2) 50 | self.assertIsInstance(result, Asig) 51 | 52 | def test_samples(self): 53 | as1 = Asig(np.ones((100, 4)), sr=100) 54 | 55 | self.assertEqual(100, as1.samples) 56 | 57 | def test_channels(self): 58 | as1 = Asig(np.ones((100, 4)), sr=100) 59 | self.assertEqual(4, as1.channels) 60 | 61 | def test_cn(self): 62 | self.assertEqual(self.astereo.cn, ['l', 'r']) 63 | self.astereo.cn = ['left', 'right'] # Test changing the cn 64 | self.assertEqual(self.astereo.cn, ['left', 'right']) 65 | with self.assertRaises(ValueError): 66 | self.astereo.cn = ['left', 'right', 'middle'] 67 | 68 | with self.assertRaises(TypeError): # If list is not string only, TypeError 69 | self.astereo.cn = ["b", 10] 70 | 71 | with self.assertRaises(TypeError): # If list is not string only, TypeError 72 | asig = Asig(1000, channels=3, cn=3) 73 | 74 | self.assertEqual(self.astereo.cn, ['left', 'right']) 75 | 76 | def test_remove_DC(self): 77 | result = self.asigconst.remove_DC() 78 | self.assertEqual(0, np.max(result.sig)) 79 | result = Asig(100, channels=2) + 0.25 80 | result[:, 1] = 0.5 81 | self.assertEqual([0.25, 0.5], list(np.max(result.sig, 0))) 82 | self.assertEqual([0., 0.], list(result.remove_DC().sig.max(axis=0))) 83 | 84 | def test_norm(self): 85 | result = self.astereo.norm() 86 | result = self.astereo.norm(norm=1., dcflag=True) 87 | self.assertEqual(1, np.max(result.sig)) 88 | result = self.astereo.norm(norm=2, dcflag=True) 89 | self.assertEqual(2, np.max(result.sig)) 90 | result = self.astereo.norm(norm=-3, dcflag=True) 91 | self.assertEqual(3, np.max(result.sig)) 92 | result = self.astereo.norm(norm=-4, in_db=True, dcflag=True) 93 | self.assertAlmostEqual(0.6309, np.max(result.sig), places=3) 94 | 95 | def test_gain(self): 96 | current_max_amplitude = np.max(self.astereo.sig) 97 | 98 | result = self.astereo.gain() # by default amp=1. nothing change. 99 | self.assertEqual(current_max_amplitude, np.max(result.sig), "gain() should not change anything") 100 | 101 | result = self.astereo.gain(amp=2.) 102 | self.assertEqual(2, np.max(result.sig)) 103 | 104 | result = self.astereo.gain(db=3.) 105 | with self.assertRaises(AttributeError): 106 | _ = self.astereo.gain(amp=1, db=3.) 107 | 108 | result = self.astereo.gain(amp=0.) 109 | self.assertEqual(0, np.max(result.sig), "amp 0 should result in 0") 110 | 111 | def test_rms(self): 112 | result = self.asine16ch.rms() 113 | 114 | def test_plot(self): 115 | self.asine.plot(xlim=(0, 1), ylim=(-1, 1)) 116 | self.asine.plot(fn='db') 117 | self.astereo.plot(offset=1) 118 | self.asine16ch.plot(offset=1, scale=0.5) 119 | 120 | def test_add(self): 121 | # # test + - * / == 122 | a = Asig(np.arange(4), sr=2, label="", channels=1) 123 | b0 = np.arange(4) + 10 124 | b1 = Asig(b0, sr=2, label="", channels=1) 125 | adding = a + b1 # asig + asig 126 | self.assertIsInstance(adding, Asig) 127 | self.assertTrue(np.array_equal([10, 12, 14, 16], adding.sig)) 128 | 129 | # asig + ndarray actually we don't encourage that. Because of sampling rate may differ 130 | # also because ndarray + asig works. so it is strongly against adding asig with ndarray. 131 | # just maker another asig and add both together. 132 | adding = a + b0 133 | self.assertIsInstance(adding, Asig) 134 | self.assertTrue(np.array_equal([10, 12, 14, 16], adding.sig)) 135 | 136 | # adding with different size will extend to the new size. 137 | b2 = Asig(np.arange(6), sr=2) 138 | with self.assertRaises(ValueError): 139 | adding = a + b2 140 | adding = a.x + b2 141 | self.assertEqual(b2.samples, 6) 142 | 143 | # TODO to decide what to do with different channels. Currently not allow. 144 | # so that it wont get too messy. 145 | b3 = Asig(np.ones((4, 2)), sr=2) 146 | # adding different channels asigs are not allowed. 147 | with self.assertRaises(ValueError): 148 | adding = a + b3 149 | 150 | # Both multichannels are ok. 151 | adding = b3 + b3 152 | self.assertTrue(np.array_equal(np.ones((4, 2)) + np.ones((4, 2)), adding.sig)) 153 | 154 | # Test bound mode. In the audio is not extended. but bound to the lower one. 155 | adding = a.bound + b2 156 | self.assertEqual(adding.samples, 4) 157 | 158 | adding = b2.bound + a 159 | self.assertEqual(adding.samples, 4) 160 | 161 | def test_mul(self): 162 | # Testing multiplication beween asig and asig, or asig with a scalar. 163 | a = Asig(np.arange(4), sr=2) 164 | a2 = Asig(np.arange(8), sr=2) 165 | a4ch = Asig(np.ones((4, 4)), sr=2) 166 | a4ch2 = Asig(np.ones((8, 4)), sr=2) 167 | 168 | self.assertTrue(np.array_equal([0, 4, 8, 12], (a * 4).sig)) 169 | self.assertTrue(np.array_equal([0, 4, 8, 12], (4 * a).sig)) 170 | self.assertTrue(np.array_equal([0, 1, 4, 9], (a * a).sig)) 171 | self.assertTrue(np.array_equal([0, 1, 4, 9], (a.bound * a2).sig)) 172 | self.assertTrue(np.array_equal([0., 1., 4., 9., 4., 5., 6., 7.], (a.x * a2).sig)) 173 | 174 | def test_subtract(self): 175 | a = Asig(np.arange(4), sr=2) 176 | b = Asig(np.ones(4), sr=2) 177 | self.assertTrue(np.array_equal([-1, 0, 1, 2], (a - 1).sig)) 178 | self.assertTrue(np.array_equal([1, 0, -1, -2], (1 - a).sig)) 179 | self.assertTrue(np.array_equal([-1, 0, 1, 2], (a - b).sig)) 180 | self.assertTrue(np.array_equal([1, 0, -1, -2], (b - a).sig)) 181 | a = Asig(np.arange(4), sr=2) 182 | b = Asig(np.ones(6), sr=2) 183 | self.assertTrue(np.array_equal([-1, 0, 1, 2], (a.bound - b).sig)) 184 | with self.assertRaises(ValueError): 185 | adding = a - b 186 | self.assertTrue(np.array_equal([-1, 0, 1, 2, -1, -1], (a.x - b).sig)) 187 | 188 | def test_division(self): 189 | # Testing multiplication beween asig and asig, or asig with a scalar. 190 | # 191 | a = Asig(np.arange(4), sr=2) 192 | a2 = Asig(np.arange(8), sr=2) 193 | a4ch = Asig(np.ones((4, 4)), sr=2) 194 | a4ch2 = Asig(np.ones((8, 4)), sr=2) 195 | 196 | self.assertTrue(np.array_equal([0, 0.25, 0.5, 0.75], (a / 4).sig)) 197 | self.assertTrue(np.allclose([inf, 4, 2, 1.33333333], (4 / a).sig)) 198 | self.assertTrue(np.array_equal(np.ones((4, 4)) / 2, (a4ch / 2).sig)) 199 | 200 | def test_windowing(self): 201 | asig = Asig(np.ones(10), sr=2) 202 | asig_windowed = asig.window_op(nperseg=2, stride=1, 203 | win='hann', fn='rms', pad='mirror') 204 | self.assertTrue(np.allclose([1., 0.70710677, 0.70710677, 0.70710677, 205 | 0.70710677, 0.70710677, 0.70710677, 206 | 0.70710677, 0.70710677, 1.], 207 | asig_windowed.sig)) 208 | 209 | asig2ch = Asig(np.ones((10, 2)), sr=2) 210 | asig2ch.window_op(nperseg=2, stride=1, win='hann', fn='rms', pad='mirror') 211 | a = [1., 0.70710677, 0.70710677, 0.70710677, 212 | 0.70710677, 0.70710677, 0.70710677, 213 | 0.70710677, 0.70710677, 1.] 214 | res = np.array([a, a]).T 215 | self.assertTrue(np.allclose(a, asig_windowed.sig)) 216 | 217 | def test_convolve(self): 218 | # Do self autocorrelatin, the middle point should always have a corr val near 1.0 219 | test = Asig(np.sin(np.arange(0, 21)), sr=21) 220 | result = test.convolve(test.sig[::-1], mode='same') 221 | # The middle point should have high corr 222 | self.assertTrue(result.sig[10] > 0.99, msg="middle point of a self correlation should always has high corr val.") 223 | # Test different modes 224 | self.assertEqual(result.samples, test.samples, msg="'same' mode should result in the same size") 225 | result = test.convolve(test.sig[::-1], mode='full') 226 | 227 | self.assertEqual(result.samples, test.samples * 2 - 1, msg="full mode should have 2x - 1 samples.") 228 | 229 | # Test input type 230 | ir = Asig(test.sig[::-1], sr=21) 231 | result = test.convolve(ir, mode='same') 232 | self.assertTrue(result.sig[10] > 0.99, msg="middle point of a self correlation should always has high corr val.") 233 | 234 | with self.assertRaises(TypeError, msg="ins can only be array or Asig"): 235 | result = test.convolve("string input") 236 | 237 | # test signal is multichannel 238 | # TODO define what to check. 239 | test = Asig(np.ones((20, 10)), sr=20) 240 | result = test.convolve(test) 241 | 242 | # # At the top I import Asig by: from pya import Asig 243 | @mock.patch("pya.asig.wavfile") 244 | def test_save_wavefile(self, mock_wavfile): 245 | 246 | test = Asig(np.array([0, 0.2, 0.4, 0.6, 0.8, 1.0]), sr=6) 247 | test.save_wavfile(fname="mock save") 248 | mock_wavfile.write.assert_called_once() 249 | 250 | # int 16 251 | test = Asig(np.array([0, 0.2, 0.4, 0.6, 0.8, 1.0]), sr=6) 252 | test.save_wavfile(dtype="int16") 253 | 254 | # unit8 255 | test = Asig(np.array([0, 0.2, 0.4, 0.6, 0.8, 1.0]), sr=6) 256 | test.save_wavfile(dtype="uint8") 257 | -------------------------------------------------------------------------------- /tests/test_aspec.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import Ugen, Aspec, Asig 3 | import warnings 4 | import numpy as np 5 | 6 | 7 | class TestAspec(TestCase): 8 | 9 | def setUp(self): 10 | self.asig = Ugen().sine() 11 | self.asig2 = Ugen().sine(channels=2, cn=['a', 'b']) 12 | self.asig_no_name = Ugen().sine(channels=3) 13 | 14 | def tearDown(self): 15 | pass 16 | 17 | def test_constructor(self): 18 | aspec = self.asig.to_spec() 19 | self.assertEqual(aspec.sr, 44100) 20 | aspec = self.asig2.to_spec() 21 | self.assertEqual(aspec.cn, self.asig2.cn) 22 | # The input can also be just an numpy array 23 | sig = Ugen().square().sig 24 | aspec = Aspec(sig, sr=400, label='square', cn=['a']) 25 | self.assertEqual(aspec.sr, 400) 26 | self.assertEqual(aspec.label, 'square') 27 | self.assertEqual(aspec.cn, ['a']) 28 | with self.assertRaises(TypeError): 29 | _ = Aspec(x=3) 30 | print(aspec) 31 | 32 | def test_plot(self): 33 | self.asig.to_spec().plot() 34 | self.asig.to_spec().plot(xlim=(0, 0.5), ylim=(0., 1.0)) 35 | 36 | def test_cn_conflict(self): 37 | with warnings.catch_warnings(record=True): 38 | _ = Aspec(self.asig, cn=['jf', 'dj']) 39 | -------------------------------------------------------------------------------- /tests/test_astft.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from pya import Ugen, Astft 3 | import numpy as np 4 | 5 | 6 | class MockPlot(mock.MagicMock): 7 | pass 8 | 9 | 10 | class TestAstft(TestCase): 11 | 12 | def setUp(self): 13 | self.asig = Ugen().sine() 14 | self.asig2 = Ugen().sine(channels=2, cn=['a', 'b']) 15 | self.asig_no_name = Ugen().sine(channels=3) 16 | 17 | def tearDown(self): 18 | pass 19 | 20 | def test_input_as_asig(self): 21 | astft = self.asig.to_stft() 22 | self.assertEqual(astft.sr, 44100) 23 | astft = self.asig.to_stft(sr=2000) 24 | self.assertEqual(astft.sr, 2000) 25 | signal = self.asig2.sig 26 | 27 | def test_wrong_input_type(self): 28 | with self.assertRaises(TypeError): 29 | asig = Astft(x=3, sr=500) 30 | 31 | def test_multichannel_asig(self): 32 | # Test conversion of a multi channel asig's astft. 33 | asine = Ugen().sawtooth(channels=3, cn=['a', 'b', 'c']) 34 | astft = asine.to_stft() 35 | self.assertEqual(astft.channels, 3) 36 | self.assertEqual(len(astft.cn), 3) 37 | 38 | def test_input_as_stft(self): 39 | sr = 10e3 40 | N = 1e5 41 | amp = 2 * np.sqrt(2) 42 | noise_power = 0.01 * sr / 2 43 | time = np.arange(N) / float(sr) 44 | mod = 500 * np.cos(2 * np.pi * 0.25 * time) 45 | carrier = amp * np.sin(2 * np.pi * 3e3 * time + mod) 46 | noise = np.random.normal(scale=np.sqrt(noise_power), size=time.shape) 47 | noise *= np.exp(-time / 5) 48 | x = carrier + noise 49 | astft = Astft(x, sr, label="test") 50 | 51 | def test_plot(self): 52 | self.asig.to_stft().plot() -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from .helpers import wait 2 | from .test_play import TestPlayBase 3 | from .test_arecorder import TestArecorderBase 4 | from pya import Arecorder 5 | from pya.backend import DummyBackend 6 | from unittest import TestCase, skipUnless 7 | 8 | try: 9 | from pya.backend import JupyterBackend 10 | has_j_backend = True 11 | except: 12 | has_j_backend = False 13 | 14 | 15 | # check if we have an output device 16 | class TestDummyBackendPlay(TestPlayBase): 17 | 18 | __test__ = True 19 | backend = DummyBackend() 20 | 21 | 22 | class TestDummyBackendRecord(TestArecorderBase): 23 | 24 | __test__ = True 25 | backend = DummyBackend() 26 | 27 | 28 | class TestJupyterBackendPlay(TestCase): 29 | @skipUnless(has_j_backend, "pya has no Jupyter Backend installed.") 30 | def test_boot(self): 31 | b = JupyterBackend() 32 | s = b.open(channels=2, rate=44100) 33 | is_running = wait(s.loop.is_running, seconds=10) 34 | self.assertTrue(is_running) 35 | s.close() 36 | self.assertFalse(s.thread.is_alive()) 37 | -------------------------------------------------------------------------------- /tests/test_class_transformation.py: -------------------------------------------------------------------------------- 1 | # Test change between asig, astft and aspec. 2 | from pya import Asig, Aspec, Astft, Ugen 3 | from unittest import TestCase 4 | 5 | 6 | class TestClassTransform(TestCase): 7 | 8 | def setUp(self): 9 | pass 10 | 11 | def tearDown(self): 12 | pass 13 | 14 | def test_asig_aspec(self): 15 | # Create a signale with 3 sine waves and gaps inbetween, 16 | # So that it will finds 3 events 17 | a = Ugen().sine() 18 | a_spec = a.to_spec() 19 | a_sig_from_aspec = a_spec.to_sig() 20 | self.assertIsInstance(a, Asig) 21 | self.assertIsInstance(a_spec, Aspec) 22 | self.assertIsInstance(a_sig_from_aspec, Asig) 23 | 24 | def test_asig_astf(self): 25 | a = Ugen().square() 26 | a_stft = a.to_stft() 27 | a_sig_from_stft = a_stft.to_sig() 28 | self.assertIsInstance(a, Asig) 29 | self.assertIsInstance(a_stft, Astft) 30 | self.assertIsInstance(a_sig_from_stft, Asig) -------------------------------------------------------------------------------- /tests/test_codestyle.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import pycodestyle 3 | 4 | 5 | class TestCodeFormat(unittest.TestCase): 6 | 7 | def test_conformance(self): 8 | """Test that we conform to PEP-8.""" 9 | # E731 ignores lamda, W291 trailing whitespace 10 | # W391 blank line at end of file 11 | # W292 no newline at end of file 12 | # E722 bare except 13 | style = pycodestyle.StyleGuide(quiet=False, 14 | ignore=['E501', 'E731', 'W291', 'W504', 15 | 'W391', 'W292', 'E722', 'E402']) 16 | # style.input_dir('../../pya') 17 | style.input_dir('./pya') 18 | style.input_dir('./tests') 19 | result = style.check_files() 20 | self.assertEqual(0, result.total_errors, 21 | "Found code style errors (and warnings).") 22 | -------------------------------------------------------------------------------- /tests/test_file_loader.py: -------------------------------------------------------------------------------- 1 | from pya import Asig 2 | from unittest import TestCase 3 | 4 | 5 | class TestLoadFile(TestCase): 6 | """Test loading audio file.""" 7 | def setUp(self): 8 | pass 9 | 10 | def tearDown(self): 11 | pass 12 | 13 | def test_wav(self): 14 | asig = Asig("./examples/samples/stereoTest.wav") 15 | self.assertEqual(2, asig.channels) 16 | 17 | def test_aiff(self): 18 | asig = Asig("./examples/samples/notes_sr32000_stereo.aif") 19 | self.assertEqual(32000, asig.sr) 20 | 21 | def test_mp3(self): 22 | asig = Asig("./examples/samples/ping.mp3") 23 | self.assertEqual(34158, asig.samples) -------------------------------------------------------------------------------- /tests/test_find_events.py: -------------------------------------------------------------------------------- 1 | from pya import Asig, Ugen 2 | from unittest import TestCase 3 | 4 | 5 | class TestFindEvents(TestCase): 6 | 7 | def setUp(self): 8 | pass 9 | 10 | def tearDown(self): 11 | pass 12 | 13 | def test_events(self): 14 | # Create a signale with 3 sine waves and 15 | # gaps inbetween, So that it will finds 3 events""" 16 | a = Ugen().sine() 17 | a.x[a.samples:] = Asig(0.2) 18 | a.x[a.samples:] = Ugen().sine(freq=200) 19 | a.x[a.samples:] = Asig(0.2) 20 | a.x[a.samples:] = Ugen().sine(freq=20) 21 | a.x[a.samples:] = Asig(0.2) 22 | a.find_events(sil_thr=-30, evt_min_dur=0.2, sil_min_dur=0.04) 23 | self.assertEqual(3, a._['events'].shape[0]) 24 | -------------------------------------------------------------------------------- /tests/test_getitem.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import * 3 | import numpy as np 4 | # import logging 5 | # logging.basicConfig(level=logging.DEBUG) 6 | 7 | 8 | class TestSlicing(TestCase): 9 | 10 | def setUp(self): 11 | self.sig = np.sin(2 * np.pi * 100 * np.linspace(0, 1, 44100)) 12 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 13 | self.sig4 = np.sin(2 * np.pi * 100 * np.linspace(0, 4, 44100 * 4)) # 4second sine 14 | self.asine4 = Asig(self.sig4, sr=44100, label="test_sine") 15 | self.sig2ch = np.repeat(self.sig, 2).reshape((44100, 2)) 16 | self.astereo = Asig(self.sig2ch, sr=44100, label="stereo", cn=['l', 'r']) 17 | 18 | def tearDown(self): 19 | pass 20 | 21 | def test_int(self): 22 | # Integer getitem 23 | self.assertAlmostEqual(self.asine4[4].sig, self.sig4[4]) 24 | 25 | def test_intlist(self): 26 | # Integer list test. """ 27 | self.assertTrue(np.allclose(self.asine[[2, 4, 5]].sig, self.sig[[2, 4, 5]])) 28 | 29 | def test_namelist(self): 30 | # Check whether I can pass a list of column names and get the same result""" 31 | result = self.astereo[:, ["l", "r"]] 32 | expect = self.astereo[:, [0, 1]] 33 | self.assertEqual(result, expect) 34 | 35 | def test_bytes(self): 36 | result = self.astereo[:, bytes([0, 1])] 37 | expect = self.astereo[:, [0, 1]] 38 | self.assertEqual(result, expect) 39 | 40 | def test_numpy_array(self): 41 | result = self.astereo[:, np.array([1, 0])] 42 | expect = self.astereo[:, [1, 0]] 43 | self.assertEqual(result, expect) 44 | 45 | def test_invalid_index(self): 46 | # pass false class or module instance should return initial signal 47 | self.assertEqual(self.astereo[:, self.astereo], self.astereo) 48 | self.assertEqual(self.astereo[:, np], self.astereo) 49 | 50 | def test_timeSlicing(self): 51 | # Check whether time slicing equals sample slicing.""" 52 | result = self.asine[{0: 1.0}] 53 | expect = self.asine[:44100] 54 | self.assertEqual(expect, result) 55 | 56 | # Check negative time work""" 57 | result2 = self.asine4[{1: -1}] # Play from 1s. to the last 1.s 58 | expect2 = self.asine4[44100: -44100] 59 | self.assertEqual(expect2, result2) 60 | 61 | def test_tuple(self): 62 | # single channel, jump sample 63 | result = self.astereo[0:44100:2, 0] 64 | expected_sig = self.astereo.sig[0:44100:2, 0] 65 | self.assertTrue(np.array_equal(result.sig, expected_sig)) 66 | 67 | result = self.astereo[0:10:2, ['l']] 68 | expected_sig = self.astereo.sig[0:10:2, 0] 69 | self.assertTrue(np.array_equal(result.sig, expected_sig)) # Check if signal equal 70 | self.assertEqual(result.cn, ['l']) # Check whether the new column name is correct 71 | 72 | # channel name slice as list. 73 | # ("both channels using col_name") 74 | result = self.astereo[0:44100:2, ['l', 'r']] 75 | expected_sig = self.astereo.sig[0:44100:2, :] 76 | self.assertTrue(np.array_equal(result.sig, expected_sig)) 77 | 78 | # Bool slice 79 | # ("bool list channel selection") 80 | # This is a special case for scling as numpy return (n, 1) rather than (n,) if we use 81 | # bool list to single out a channel. 82 | result = self.astereo[360:368, [False, True]] 83 | expected_sig = self.astereo.sig[360:368:1, [False, True]] 84 | self.assertTrue(np.array_equal(result.sig, expected_sig)) 85 | # time slicing 86 | result = self.astereo[{1: -1}, 0] # Play from 1s. to the last 1.s 87 | expect = self.astereo[44100: -44100, 0] 88 | self.assertEqual(expect, result) 89 | 90 | # time slicing 91 | # ("time slicing.") 92 | time_range = {1: -1} # first to last second. 93 | result = self.astereo[time_range, :] # Play from 1s. to the last 1.s 94 | expect = self.astereo[44100: -44100, :] 95 | self.assertEqual(expect, result) 96 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import Asig, Ugen 3 | from pya.helper import spectrum, padding, next_pow2, is_pow2 4 | from pya.helper import signal_to_frame, magspec, powspec 5 | 6 | import numpy as np 7 | 8 | 9 | class TestHelpers(TestCase): 10 | """Test helper functions 11 | """ 12 | 13 | def setUp(self): 14 | self.sig = np.sin(2 * np.pi * 100 * np.linspace(0, 1, 44100)) 15 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 16 | self.asineWithName = Asig(self.sig, sr=44100, 17 | label="test_sine", cn=['sine']) 18 | self.sig2ch = np.repeat(self.sig, 2).reshape((44100, 2)) 19 | self.astereo = Asig(self.sig2ch, sr=44100, label="sterep", 20 | cn=['l', 'r']) 21 | self.sig16ch = np.repeat(self.sig, 16).reshape((44100, 16)) 22 | self.asine16ch = Asig(self.sig16ch, sr=44100, 23 | label="test_sine_16ch") 24 | 25 | def tearDown(self): 26 | pass 27 | 28 | def test_spectrum(self): 29 | # Not tested expected outcome yet. 30 | frq, Y = spectrum(self.asine.sig, self.asine.samples, self.asine.channels, self.asine.sr) 31 | frqs, Ys = spectrum(self.astereo.sig, self.astereo.samples, self.astereo.channels, self.astereo.sr) 32 | 33 | def test_padding(self): 34 | """Pad silence to signal. Support 1-3D tensors.""" 35 | tensor1 = np.arange(5) 36 | padded = padding(tensor1, 2, tail=True) 37 | self.assertTrue(np.array_equal(padded, np.array([0, 1, 2, 3, 4, 0, 0]))) 38 | padded = padding(tensor1, 2, tail=False) 39 | self.assertTrue(np.array_equal(padded, np.array([0, 0, 0, 1, 2, 3, 4]))) 40 | 41 | tensor2 = np.ones((3, 3)) 42 | padded = padding(tensor2, 2, tail=True) 43 | self.assertTrue(np.array_equal(padded, np.array([[1., 1., 1.], [1., 1., 1.], [1., 1., 1.], 44 | [0., 0., 0.], [0., 0., 0.]]))) 45 | padded = padding(tensor2, 2, tail=False, constant_values=5) 46 | self.assertTrue(np.array_equal(padded, np.array([[5., 5., 5.], [5., 5., 5.], 47 | [1., 1., 1.], [1., 1., 1.], [1., 1., 1.]]))) 48 | 49 | tensor3 = np.ones((2, 2, 2)) 50 | padded = padding(tensor3, 2) 51 | self.assertTrue(np.array_equal(padded, np.array([[[1., 1.], 52 | [1., 1.], 53 | [0., 0.], 54 | [0., 0.]], 55 | [[1., 1.], 56 | [1., 1.], 57 | [0., 0.], 58 | [0., 0.]]]))) 59 | padded = padding(tensor3, 2, tail=False) 60 | self.assertTrue(np.array_equal(padded, np.array([[[0., 0.], 61 | [0., 0.], 62 | [1., 1.], 63 | [1., 1.] 64 | ], 65 | [[0., 0.], 66 | [0., 0.], 67 | [1., 1.], 68 | [1., 1.]]]))) 69 | 70 | def test_next_pow2(self): 71 | next = next_pow2(255) 72 | self.assertEqual(next, 256) 73 | 74 | next = next_pow2(0) 75 | self.assertEqual(next, 2) 76 | 77 | next = next_pow2(256) 78 | self.assertEqual(next, 256) 79 | 80 | with self.assertRaises(AttributeError): 81 | _ = next_pow2(-2) 82 | 83 | def test_is_pow2(self): 84 | self.assertTrue(is_pow2(2)) 85 | self.assertTrue(is_pow2(256)) 86 | self.assertTrue(is_pow2(1)) 87 | self.assertFalse(is_pow2(145)) 88 | self.assertFalse(is_pow2(-128)) 89 | self.assertFalse(is_pow2(0)) 90 | 91 | def test_signal_to_frame(self): 92 | sq = Ugen().square(freq=20, sr=8000, channels=1) 93 | frames = signal_to_frame(sq.sig, 400, 400) 94 | self.assertEqual(frames.shape, (20, 400)) 95 | frames = signal_to_frame(sq.sig, 400, 200) 96 | self.assertEqual(frames.shape, (39, 400)) 97 | 98 | def test_magspec_pspec(self): 99 | # Magnitude spectrum 100 | sq = Ugen().square(freq=20, sr=8000, channels=1) 101 | frames = signal_to_frame(sq.sig, 400, 400) 102 | mag = magspec(frames, 512) 103 | self.assertEqual(mag.shape, (20, 257)) 104 | self.assertTrue((mag >= 0.).all()) # All elements should be non-negative 105 | ps = powspec(frames, 512) 106 | self.assertEqual(ps.shape, (20, 257)) 107 | self.assertTrue((ps >= 0.).all()) # All elements should be non-negative 108 | -------------------------------------------------------------------------------- /tests/test_play.py: -------------------------------------------------------------------------------- 1 | from .helpers import check_for_output 2 | import time 3 | from unittest import TestCase, skipUnless, mock 4 | from pya import * 5 | import numpy as np 6 | import warnings 7 | import pytest 8 | 9 | 10 | # check if we have an output device 11 | has_output = check_for_output() 12 | 13 | 14 | class MockAudio(mock.MagicMock): 15 | channels_in = 1 16 | channels_out = 4 17 | 18 | def get_device_info_by_index(self, *args): 19 | return {'maxInputChannels': self.channels_in, 'maxOutputChannels': self.channels_out, 20 | 'name': 'MockAudio', 'index': 42} 21 | 22 | 23 | class TestPlayBase(TestCase): 24 | 25 | __test__ = False # important, makes sure tests are not run on base class 26 | backend = None # will be overridden by backend tests 27 | 28 | def setUp(self): 29 | self.sig = np.sin(2 * np.pi * 440 * np.linspace(0, 1, 44100)) 30 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 31 | self.asineWithName = Asig(self.sig, sr=44100, label="test_sine", cn=['sine']) 32 | self.sig2ch = np.repeat(self.sig, 2).reshape((44100, 2)) 33 | self.astereo = Asig(self.sig2ch, sr=44100, label="stereo", cn=['l', 'r']) 34 | 35 | @pytest.mark.xfail(reason="Test may get affect with PortAudio bug or potential unsuitable audio device.") 36 | def test_play_and_stop(self): 37 | ser = Aserver(backend=self.backend) 38 | ser.boot() 39 | self.asine.play(server=ser) 40 | time.sleep(2) 41 | ser.stop() 42 | ser.quit() 43 | 44 | def test_gain(self): 45 | result = (self.asine * 0.2).sig 46 | expected = self.asine.sig * 0.2 47 | self.assertTrue(np.allclose(result, expected)) # float32 should use allclose for more forgiving precision 48 | 49 | expected = self.sig * self.sig 50 | result = (self.asine * self.asine).sig 51 | self.assertTrue(np.allclose(result, expected)) 52 | 53 | def test_resample(self): 54 | # This test currently only check if there is error running the code, but not whether resampling is correct 55 | result = self.asine.resample(target_sr=44100 // 2, rate=1, kind='linear') 56 | self.assertIsInstance(result, Asig) 57 | 58 | 59 | @skipUnless(has_output, "PyAudio found no output device.") 60 | class TestPlay(TestPlayBase): 61 | 62 | __test__ = True 63 | 64 | 65 | # class MockAudioTest(TestCase): 66 | 67 | # # @skipUnless(has_output, "PyAudio found no output device.") 68 | # def test_play(self): 69 | # # Shift a mono signal to chan 4 should result in a 4 channels signals 70 | # mock_audio = MockAudio() 71 | # with mock.patch('pyaudio.PyAudio', return_value=mock_audio): 72 | # s = Aserver() 73 | # s.boot() 74 | # assert mock_audio.open.called 75 | # # since default AServer channel output is stereo we expect open to be called with 76 | # # channels=2 77 | # self.assertEqual(mock_audio.open.call_args_list[0][1]["channels"], 2) 78 | # d1 = np.linspace(0, 1, 44100) 79 | # d2 = np.linspace(0, 1, 44100) 80 | # asig = Asig(d1) 81 | # s.play(asig) 82 | # self.assertTrue(np.allclose(s.srv_asigs[0].sig, d2.reshape(44100, 1))) 83 | 84 | # with mock.patch('pyaudio.PyAudio', return_value=mock_audio): 85 | # with warnings.catch_warnings(record=True): 86 | # s = Aserver(channels=6) 87 | # s.boot() 88 | # assert mock_audio.open.call_count == 2 89 | # self.assertEqual(mock_audio.open.call_args_list[1][1]["channels"], 6) 90 | 91 | -------------------------------------------------------------------------------- /tests/test_routeNpan.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import warnings 3 | import numpy as np 4 | from pya import * 5 | # import logging 6 | # logging.basicConfig(level=logging.DEBUG) 7 | 8 | 9 | class TestRoutePan(TestCase): 10 | """Test route, rewire, mono, stereo, pan2 methods""" 11 | 12 | def setUp(self): 13 | self.sig = np.sin(2 * np.pi * 100 * np.linspace(0, 1, 44100)) 14 | self.asine = Asig(self.sig, sr=44100, label="test_sine") 15 | self.asineWithName = Asig(self.sig, sr=44100, label="test_sine", cn=['sine']) 16 | self.sig2ch = np.repeat(self.sig, 2).reshape((44100, 2)) 17 | self.astereo = Asig(self.sig2ch, sr=44100, label="sterep", cn=['l', 'r']) 18 | self.sig16ch = np.repeat(self.sig, 16).reshape((44100, 16)) 19 | self.asine16ch = Asig(self.sig16ch, sr=44100, label="test_sine_16ch") 20 | 21 | def tearDown(self): 22 | pass 23 | 24 | def test_shift_channel(self): 25 | # Shift a mono signal to chan 4 should result in a 4 channels signals""" 26 | result = self.asine.shift_channel(3) 27 | self.assertEqual(4, result.channels) 28 | 29 | result = self.asineWithName.shift_channel(3) 30 | self.assertEqual(4, result.channels) 31 | 32 | def test_mono(self): 33 | # Convert any signal dimension to mono""" 34 | with warnings.catch_warnings(record=True): 35 | self.asine.mono() 36 | result = self.astereo.mono([0.5, 0.5]) 37 | self.assertEqual(result.channels, 1) 38 | result = self.asine16ch.mono(np.ones(16) * 0.1) 39 | self.assertEqual(result.channels, 1) 40 | 41 | def test_stereo(self): 42 | # Convert any signal dimension to stereo""" 43 | stereo_mix = self.asine.stereo([0.5, 0.5]) 44 | self.assertEqual(stereo_mix.channels, 2) 45 | 46 | stereo_mix = self.astereo.stereo() 47 | stereo_mix = self.astereo.stereo(blend=(0.2, 0.4)) 48 | 49 | stereo_mix = self.asine16ch.stereo([np.ones(16), np.zeros(16)]) 50 | self.assertEqual(stereo_mix.channels, 2) 51 | 52 | def test_rewire(self): 53 | # Rewire channels, e.g. move 0 to 1 with a gain of 0.5""" 54 | result = self.astereo.rewire({(0, 1): 0.5, 55 | (1, 0): 0.5}) 56 | temp = self.astereo.sig 57 | expect = temp.copy() 58 | expect[:, 0] = temp[:, 1] * 0.5 59 | expect[:, 1] = temp[:, 0] * 0.5 60 | self.assertTrue(np.allclose(expect[1000:10010, 1], result.sig[1000:10010, 1])) 61 | 62 | def test_pan2(self): 63 | pan2 = self.astereo.pan2(-1.) 64 | self.assertAlmostEqual(0, pan2.sig[:, 1].sum()) 65 | pan2 = self.astereo.pan2(1.) 66 | self.assertAlmostEqual(0, pan2.sig[:, 0].sum()) 67 | 68 | pan2 = self.asine.pan2(-0.5) 69 | self.assertEqual(pan2.channels, 2) 70 | with self.assertRaises(TypeError): 71 | self.astereo.pan2([2., 4.]) 72 | with self.assertRaises(ValueError): 73 | self.astereo.pan2(3.) -------------------------------------------------------------------------------- /tests/test_setitem.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import * 3 | import numpy as np 4 | # import logging 5 | # logging.basicConfig(level=logging.DEBUG) 6 | 7 | 8 | class TestSetitem(TestCase): 9 | 10 | def setUp(self): 11 | self.dur = 3.5 12 | self.sr = 1000 13 | self.ts = np.linspace(0, self.dur, int(self.dur * self.sr)) 14 | self.sig = np.sin(2 * np.pi * 50 * self.ts ** 1.9) 15 | self.a1 = Asig(self.sig, sr=self.sr, channels=1, cn=['a'], label='1ch-sig') 16 | self.ak = Asig(np.tile(self.sig.reshape(3500, 1), (1, 4)), sr=self.sr, 17 | label='4ch-sig', cn=['a', 'b', 'c', 'd']) 18 | self.one = np.ones(self.sr) 19 | self.aones = Asig(self.one, sr=self.sr, cn=['o'], label='ones') 20 | self.zero = np.zeros(self.sr) 21 | self.azeros = Asig(self.zero, sr=self.sr, cn=['z'], label='zeros') 22 | self.noise = np.random.random(self.sr) 23 | self.anoise = Asig(self.noise, sr=self.sr, cn=['n'], label='noise') 24 | self.aramp = Asig(np.arange(1000), sr=self.sr, label='ramp') 25 | 26 | def tearDown(self): 27 | pass 28 | 29 | def test_default(self): 30 | # Testing of default mode, which should behave as Numpy should.""" 31 | self.azeros[10] = self.aones[10].sig # value as asig 32 | self.assertEqual(self.aones[10], self.azeros[10]) 33 | self.azeros[2] = self.aones[4].sig # value as ndarray 34 | self.assertEqual(self.aones[4], self.azeros[2]) 35 | self.azeros[3:6] = [1, 2, 3] # value as list 36 | self.assertTrue(np.array_equal(self.azeros[3:6].sig, [1, 2, 3])) 37 | r = self.azeros 38 | r[3:6] = np.array([3, 4, 5]) 39 | self.assertTrue(np.array_equal(r[3:6].sig, np.array([3, 4, 5]))) 40 | r = self.azeros[:10] # value as asig 41 | self.assertTrue(r, self.azeros[:10]) 42 | self.azeros[{0.2: 0.4}] = self.anoise[{0.5: 0.7}] 43 | self.ak[{1: 2}, ['d']] = self.ak[{0: 1}, ['a']] 44 | self.assertTrue(np.array_equal(self.ak[{1: 2}, ['d']], self.ak[{0: 1}, ['a']])) 45 | 46 | def test_bound(self): 47 | # Testing of bound mode. Redundant array will not be assigned""" 48 | subject = self.aramp 49 | subject.b[:10] += self.aones[:10] # This case should be the same as default 50 | result = np.arange(10) + np.ones(10) 51 | self.assertTrue(np.array_equal(subject[:10].sig, result)) 52 | 53 | subject = self.aramp # set 10 samples with 20 samples in bound mode 54 | subject.b[-10:] += np.arange(20) 55 | result = np.arange(1000)[-10:] + np.arange(10) 56 | self.assertTrue(np.array_equal(subject[-10:].sig, result)) 57 | 58 | subject = Asig(np.arange(1000), sr=self.sr, label='ramp') 59 | subject.b[-10:] *= 2 # Test __mul__ also. 60 | result = np.arange(1000)[-10:] * 2 61 | self.assertTrue(np.array_equal(subject[-10:].sig, result)) 62 | 63 | # # Multi channel case 64 | self.ak.b[{2: None}, ['a', 'b']] = np.zeros(shape=(3000, 2)) 65 | result = np.sum(self.ak[{2: None}, ['a', 'b']].sig) 66 | self.assertEqual(result, 0.0) 67 | 68 | def test_extend(self): 69 | # Testing of extend mode, longer array will force the taker to extend its shape.""" 70 | a = Asig(0.8, sr=1000, channels=4, cn=['a', 'b', 'c', 'd']) 71 | b = np.sin(2 * np.pi * 100 * np.linspace(0, 0.6, int(1000 * 0.6))) 72 | b = Asig(b) 73 | # test with extend set mono signal to a, initially only 0.8secs long... 74 | a.x[:, 0] = 0.2 * b # this fits in without need to extend 75 | self.assertEqual(a.samples, 800) 76 | a.x[300:, 1] = 0.5 * b 77 | self.assertEqual(a.samples, 900) 78 | a.x[1300:, 'c'] = 0.2 * b[::2] # compressed sig in ch 'c' 79 | self.assertEqual(a.samples, 1600) 80 | a.x[1900:, 3] = 0.2 * b[300:] # only end of tone in ch 'd' 81 | self.assertEqual(a.samples, 2200) 82 | 83 | a = Asig(0.8, sr=1000, channels=1, cn=['a']) # Test with mono signal 84 | b = np.sin(2 * np.pi * 100 * np.linspace(0, 0.6, int(1000 * 0.6))) 85 | b = Asig(b) 86 | a.x[:, 0] = 0.2 * b # this fits in without need to extend 87 | self.assertEqual(a.samples, 800) 88 | 89 | def test_replace(self): 90 | b = np.ones(290) 91 | a = np.sin(2 * np.pi * 40 * np.linspace(0, 1, 100)) 92 | a = Asig(a) 93 | a.overwrite[40:50] = b 94 | self.assertEqual(a.samples, 100 - 10 + 290) # First make sure size is correct 95 | c = np.sum(a[50:60].sig) # Then make sure replace value is correct 96 | self.assertEqual(c, 10) 97 | with self.assertRaises(ValueError): 98 | # Passing 2 chan to 4 chan asig should raise ValueError 99 | self.ak.overwrite[{1.: 1.5}] = np.zeros((int(44100 * 0.6), 2)) 100 | 101 | def test_numpy_index(self): 102 | self.azeros[np.arange(0, 10)] = np.ones(10) 103 | self.assertTrue(np.array_equal(self.azeros[np.arange(0, 10)].sig, self.aones[np.arange(0, 10)].sig)) 104 | 105 | def test_byte_index(self): 106 | self.azeros[bytes([0, 1, 2])] = np.ones(3) 107 | self.assertTrue(np.array_equal(self.azeros[[0, 1, 2]].sig, self.aones[[0, 1, 2]].sig)) 108 | 109 | def test_asig_index(self): 110 | self.azeros[self.aones.sig.astype(bool)] = self.aones.sig 111 | self.assertTrue(np.array_equal(np.ones(self.sr), self.azeros.sig)) 112 | 113 | def test_invalid_slicing_type(self): 114 | self.azeros[self.aones] = self.aones.sig 115 | self.assertTrue(np.array_equal(np.zeros(self.sr), self.azeros.sig)) 116 | -------------------------------------------------------------------------------- /tests/test_ugen.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import * 3 | import numpy as np 4 | 5 | 6 | class TestUgen(TestCase): 7 | def setUp(self): 8 | pass 9 | 10 | def tearDown(self): 11 | pass 12 | 13 | def test_sine(self): 14 | sine = Ugen().sine( 15 | freq=44100 / 16, amp=0.5, dur=0.001, sr=44100 // 2, channels=2 16 | ) 17 | self.assertEqual(44100 // 2, sine.sr) 18 | self.assertEqual(0.5, np.max(sine.sig)) 19 | self.assertEqual((22, 2), sine.sig.shape) 20 | sine = Ugen().sine( 21 | freq=44100 / 16, amp=0.5, n_rows=400, sr=44100 // 2, channels=2 22 | ) 23 | self.assertEqual(44100 // 2, sine.sr) 24 | self.assertEqual(0.5, np.max(sine.sig)) 25 | self.assertEqual((400, 2), sine.sig.shape) 26 | 27 | def test_cos(self): 28 | cos = Ugen().cos(freq=44100 / 16, amp=0.5, dur=0.001, sr=44100 // 2, channels=2) 29 | self.assertEqual(44100 // 2, cos.sr) 30 | self.assertEqual(0.5, np.max(cos.sig)) 31 | self.assertEqual((22, 2), cos.sig.shape) 32 | cos = Ugen().cos(freq=44100 / 16, amp=0.5, n_rows=44, sr=44100 // 2, channels=2) 33 | self.assertEqual(44100 // 2, cos.sr) 34 | self.assertEqual(0.5, np.max(cos.sig)) 35 | self.assertEqual((44, 2), cos.sig.shape) 36 | 37 | def test_square(self): 38 | square = Ugen().square(freq=200, amp=0.5, dur=1.0, sr=44100 // 2, channels=2) 39 | self.assertEqual(44100 // 2, square.sr) 40 | self.assertAlmostEqual(0.5, np.max(square.sig), places=5) 41 | self.assertEqual((44100 // 2, 2), square.sig.shape) 42 | 43 | def test_sawooth(self): 44 | saw = Ugen().sawtooth(freq=200, amp=0.5, dur=1, sr=44100 // 2, channels=2) 45 | self.assertEqual(44100 // 2, saw.sr) 46 | self.assertAlmostEqual(0.5, np.max(saw.sig), places=5) 47 | self.assertEqual((44100 // 2, 2), saw.sig.shape) 48 | 49 | def test_noise(self): 50 | white = Ugen().noise( 51 | type="white", amp=0.2, dur=1.0, sr=1000, cn=["white"], label="white_noise" 52 | ) 53 | pink = Ugen().noise(type="pink") 54 | self.assertEqual(white.sr, 1000) 55 | self.assertEqual(white.cn, ["white"]) 56 | self.assertEqual(white.label, "white_noise") 57 | white_2ch = Ugen().noise(type="pink", channels=2) 58 | self.assertEqual(white_2ch.channels, 2) 59 | 60 | def test_dur_n_rows_exception(self): 61 | # An exception should be raised if both dur and n_rows are define. 62 | with self.assertRaises(AttributeError): 63 | asig = Ugen().sine(dur=1.0, n_rows=400) 64 | -------------------------------------------------------------------------------- /tests/test_visualization.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from pya import * 3 | 4 | 5 | class TestVisualization(TestCase): 6 | 7 | def setUp(self): 8 | self.asig = Ugen().sine() 9 | self.aspec = self.asig.to_spec() 10 | self.astft = self.asig.to_stft() 11 | self.amfcc = self.asig.to_mfcc() 12 | self.alst = [self.asig, self.aspec, self.astft, self.amfcc] 13 | 14 | def tearDown(self): 15 | pass 16 | 17 | def test_asig_plot_default(self): 18 | self.asig.plot() 19 | 20 | def test_asig_plot_args(self): 21 | self.asig.plot(xlim=(0, 100), ylim=(0, 100)) 22 | 23 | def test_asig_fn_db(self): 24 | self.asig.plot(fn='db') 25 | 26 | def test_asig_fn_nocallable(self): 27 | with self.assertRaises(AttributeError): 28 | self.asig.plot(fn='something') 29 | 30 | def test_asig_multichannels(self): 31 | sig2d = Ugen().sine(channels=4, cn=['a', 'b', 'c', 'd']) 32 | sig2d.plot() 33 | 34 | def test_aspec_plot(self): 35 | self.aspec.plot() 36 | 37 | def tesst_aspec_plot_lim(self): 38 | self.aspect.plot(xlim=(0, 1.), ylim=(0, 100)) 39 | 40 | def test_gridplot(self): 41 | _ = gridplot(self.alst) 42 | 43 | def test_gridplot_valid_colwrap(self): 44 | _ = gridplot(self.alst, colwrap=3) 45 | _ = gridplot(self.alst, colwrap=2) 46 | 47 | def test_gridplot_colwrap_too_big(self): 48 | # colwrap more than list len 49 | _ = gridplot(self.alst, colwrap=5) 50 | 51 | def test_gridplot_neg_colwrap(self): 52 | with self.assertRaises(ValueError): 53 | _ = gridplot(self.alst, colwrap=-1) 54 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3, check-manifest 3 | 4 | [testenv] 5 | passenv = PULSE_SERVER 6 | deps= 7 | -rrequirements.txt 8 | -rrequirements_pyaudio.txt 9 | -rrequirements_remote.txt 10 | -rrequirements_test.txt 11 | coverage 12 | commands= 13 | pytest --doctest-modules --cov pya/ {posargs} 14 | 15 | [testenv:check-manifest] 16 | deps = check-manifest 17 | commands = check-manifest 18 | --------------------------------------------------------------------------------