├── .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 | [](https://pypi.org/project/pya)
2 | [](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) | [](https://github.com/interactive-sonification/pya/actions/workflows/pya-ci.yaml?query=branch%3Amaster) | [](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/)|  |  |
10 | |Changes|[](https://github.com/interactive-sonification/pya/compare/v0.5.0...master) | [](https://github.com/interactive-sonification/pya/compare/v0.5.0...develop) |
11 | |Binder|[](https://mybinder.org/v2/gh/interactive-sonification/pya/master?filepath=examples%2Fpya-examples.ipynb) | [](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:
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 |
--------------------------------------------------------------------------------