├── docs └── architecture.drawio.png ├── pitchshifter ├── version.py ├── __init__.py ├── resampler.py ├── utilities.py ├── stft.py ├── vocoder.py └── pitchshifter.py ├── samples └── sample1.wav ├── requirements.txt ├── setup.py ├── .gitignore ├── LICENSE ├── test └── __init__.py └── README.md /docs/architecture.drawio.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pitchshifter/version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | __VERSION__ = "0.3.0" -------------------------------------------------------------------------------- /samples/sample1.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cwoodall/pitch-shifter-py/HEAD/samples/sample1.wav -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports.functools-lru-cache==1.4 2 | cycler==0.10.0 3 | matplotlib==2.1.0 4 | numpy==1.13.3 5 | pitch-shifter==0.0.1 6 | pyparsing==2.2.0 7 | python-dateutil==2.6.1 8 | pytz==2017.3 9 | scipy==1.0.0 10 | six==1.11.0 11 | subprocess32==3.2.7 12 | -------------------------------------------------------------------------------- /pitchshifter/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # from .utilities import * 4 | # from .stft import * 5 | # from .vocoder import * 6 | # from .resampler import * 7 | import logging 8 | from .version import * 9 | 10 | log = logging.getLogger("pitchshifter") 11 | -------------------------------------------------------------------------------- /pitchshifter/resampler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import numpy as np 4 | from scipy.interpolate import interp1d 5 | 6 | def linear_resample(samples, out_len): 7 | """ 8 | Resamples samples to have length equal to out_len. 9 | 10 | Uses a 1d linear interpolator 11 | 12 | Args: 13 | samples: samples to resample using an interpolator 14 | out_len: Length of output sample size. 15 | 16 | Returns: 17 | resampled and interpolated output. 18 | """ 19 | sample_size = len(samples) 20 | interpolator = interp1d(np.arange(0, sample_size), samples, kind='linear') 21 | 22 | resample_n = np.linspace(0, sample_size-1, out_len) 23 | return interpolator(resample_n) 24 | 25 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages, Command 3 | from distutils.util import convert_path 4 | 5 | main_ns = {} 6 | ver_path = convert_path('pitchshifter/version.py') 7 | with open(ver_path) as ver_file: 8 | exec(ver_file.read(), main_ns) 9 | 10 | setup( 11 | name='pitchshifter', 12 | version=main_ns['__VERSION__'], 13 | packages=find_packages(), 14 | install_requires=[ 15 | 'click', 16 | 'numpy', 17 | 'scipy', 18 | 'matplotlib' 19 | ], 20 | setup_requires=['pytest-runner'], 21 | tests_require=['pytest', 'coverage'], 22 | description='Pitchshifting with python', 23 | author='Christopher Woodall', 24 | author_email='chris@cwoodall.com', 25 | zip_safe=False, 26 | entry_points={ 27 | "console_scripts": [ 28 | "pitchshifter=pitchshifter.pitchshifter:cli", 29 | ]}, 30 | ) 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Outputs of program 2 | out.wav 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | venv/ 12 | 13 | .eggs/ 14 | 15 | # Distribution / packaging 16 | .Python 17 | env/ 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | 57 | # Sphinx documentation 58 | docs/_build/ 59 | 60 | # PyBuilder 61 | target/ 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Christopher Woodall 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of pitch-shifter-py nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | #!python 2 | 3 | from pitchshifter import * 4 | import numpy as np 5 | 6 | class TestUtilities(object): 7 | def test_complex_polarToCartesian_correctResults(self): 8 | r = 1.4142 # magnitude 9 | theta = 0.7854 # radians 10 | results = complex_polarToCartesian(r, theta) 11 | 12 | expected_results = 1 + 1j 13 | assert abs(results.real - expected_results.real) < .01 14 | assert abs(expected_results.imag - results.imag) < .01 15 | 16 | def test_complex_cartesianToPolar_correctResults(self): 17 | results = complex_cartesianToPolar(1+1j) 18 | 19 | expected_results = (1.4142, .7854) 20 | assert abs(results[0] - expected_results[0]) < .001 21 | assert abs(results[1] - expected_results[1]) < .001 22 | 23 | def test_complex_polarToCartesian_arrayCorrectResults(self): 24 | rs = np.asarray([1.4142 for i in range(10)]) 25 | thetas = np.asarray([0.7854 for i in range(10)]) 26 | 27 | results = complex_polarToCartesian(rs, thetas) 28 | 29 | expected_results = 1 + 1j 30 | for result in results: 31 | assert abs(result.real - expected_results.real) < .01 32 | assert abs(expected_results.imag - result.imag) < .01 33 | 34 | def test_complex_cartesianToPolar_arrayCorrectResults(self): 35 | rs, thetas= complex_cartesianToPolar(np.asarray([1+1j for i in range(10)])) 36 | 37 | expected_results = (1.4142, .7854) 38 | for r in rs: 39 | assert abs(r - expected_results[0]) < .001 40 | 41 | for theta in thetas: 42 | assert abs(theta - expected_results[1]) < .001 43 | 44 | #class TestSTFT(object): 45 | # def test_stft_correct_results(self): 46 | # assert False 47 | 48 | -------------------------------------------------------------------------------- /pitchshifter/utilities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import numpy as np 4 | import scipy as sp 5 | import collections 6 | 7 | def scalar_len(a): 8 | """ 9 | 10 | Return 11 | """ 12 | if isinstance(a, collections.Iterable): 13 | return len(a) 14 | else: 15 | return 1 16 | 17 | def complex_polarToCartesian(r, theta): 18 | """ 19 | Convert a polar representation of a complex number to a cartesian 20 | representation. 21 | 22 | Can be used with a numpy array allowing for block conversions 23 | 24 | Example Usage: 25 | 26 | results = complex_polarToCartesian(1.4142, 0.7854) 27 | 28 | results approx. 1+1j 29 | """ 30 | return r * np.exp(theta*1j) 31 | 32 | def complex_cartesianToPolar(x): 33 | """ 34 | Convert a cartesian representation of a complex number to a polar 35 | representation. 36 | 37 | Can be used with a numpy array allowing for block conversions 38 | 39 | Example Usage: 40 | 41 | results = complex_cartesianToPolar(1 + 1j) 42 | 43 | results approx. (1.4142, 0.7854) 44 | """ 45 | return (np.abs(x), np.angle(x)) 46 | 47 | def stereoToMono(audio_samples): 48 | """ 49 | Takes in an 2d array of stereo samples and returns a mono numpy 50 | array of dtype np.int16. 51 | """ 52 | LEFT = 0 53 | RIGHT = 1 54 | channels = scalar_len(audio_samples[0]) 55 | if channels == 1: 56 | mono_samples = np.asarray(audio_samples, 57 | dtype=np.int16) 58 | 59 | elif channels == 2: 60 | mono_samples = np.asarray( 61 | [(sample[RIGHT] + sample[LEFT])/2 for sample in audio_samples], 62 | dtype=np.int16 63 | ) 64 | 65 | else: 66 | raise Exception("Must be mono or stereo") 67 | 68 | 69 | 70 | return mono_samples 71 | -------------------------------------------------------------------------------- /pitchshifter/stft.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import numpy as np 4 | from scipy.fft import fft, ifft 5 | from scipy.signal import hanning 6 | from scipy import array, zeros, real 7 | 8 | def stft(x, chunk_size, hop, w=None): 9 | """ 10 | Takes the short time fourier transform of x. 11 | 12 | Args: 13 | x: samples to window and transform. 14 | chunk_size: size of analysis window. 15 | hop: hop distance between analysis windows 16 | w: windowing function to apply. Must be of length chunk_size 17 | 18 | Returns: 19 | STFT of x (X(t, omega)) hop size apart with windows of size chunk_size. 20 | 21 | Raises: 22 | ValueError if window w is not of size chunk_size 23 | """ 24 | if not w: 25 | w = hanning(chunk_size) 26 | else: 27 | if len(w) != chunk_size: 28 | raise ValueError("window w is not of the correct length {0}.".format(chunk_size)) 29 | X = array([fft(w*x[i:i+chunk_size]) 30 | for i in range(0, len(x)-chunk_size, hop)])/np.sqrt(((float(chunk_size)/float(hop))/2.0)) 31 | return X 32 | 33 | def istft(X, chunk_size, hop, w=None): 34 | """ 35 | Naively inverts the short time fourier transform using an overlap and add 36 | method. The overlap is defined by hop 37 | 38 | Args: 39 | X: STFT windows to invert, overlap and add. 40 | chunk_size: size of analysis window. 41 | hop: hop distance between analysis windows 42 | w: windowing function to apply. Must be of length chunk_size 43 | 44 | Returns: 45 | ISTFT of X using an overlap and add method. Windowing used to smooth. 46 | 47 | Raises: 48 | ValueError if window w is not of size chunk_size 49 | """ 50 | 51 | if not w: 52 | w = hanning(chunk_size) 53 | else: 54 | if len(w) != chunk_size: 55 | raise ValueError("window w is not of the correct length {0}.".format(chunk_size)) 56 | 57 | x = zeros(len(X) * (hop)) 58 | i_p = 0 59 | for n, i in enumerate(range(0, len(x)-chunk_size, hop)): 60 | x[i:i+chunk_size] += w*real(ifft(X[n])) 61 | return x 62 | -------------------------------------------------------------------------------- /pitchshifter/vocoder.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from .utilities import * 3 | import numpy as np 4 | 5 | class PhaseVocoder(object): 6 | """ 7 | Implements the phase vocoder algorithm. 8 | 9 | Usage: 10 | from phaseshifter import PhaseVocoder, stft 11 | vocoder = PhaseVocoder(HOP, HOP_OUT) 12 | phase_corrected_frames = [frame for frame in vocoder.sendFrames(frames)] 13 | 14 | Attributes: 15 | input_hop: Input hop distance/size 16 | output_hop: Output hop distance/size 17 | last_phase: numpy array of all of the previous frames phase information. 18 | phase_accumulator: numpy array of accumulated phases. 19 | """ 20 | 21 | def __init__(self, ihop, ohop): 22 | """ 23 | Initialize the phase vocoder with the input and output hop sizes desired. 24 | 25 | Args: 26 | ihop: input hop size 27 | ohop: output hop size 28 | """ 29 | self.input_hop = int(ihop) 30 | self.output_hop = int(ohop) 31 | self.reset() 32 | 33 | def reset(self): 34 | """ 35 | Reset the phase accumulator and the previous phase stored to 0. 36 | """ 37 | self.last_phase = 0 38 | self.phase_accumulator = 0 39 | 40 | def sendFrame(self, frame): 41 | """ 42 | Send a single frame to the phase vocoder 43 | 44 | Args: 45 | frame: frame of FFT information. 46 | 47 | Returns: phase corrected frame 48 | """ 49 | omega_bins = 2*np.pi*np.arange(len(frame))/len(frame) 50 | magnitude, phase = complex_cartesianToPolar(frame) 51 | 52 | delta_phase = phase - self.last_phase 53 | self.last_phase = phase 54 | 55 | delta_phase_unwrapped = delta_phase - self.input_hop * omega_bins 56 | delta_phase_rewrapped = np.mod(delta_phase_unwrapped + np.pi, 2*np.pi) - np.pi 57 | 58 | true_freq = omega_bins + delta_phase_rewrapped/self.input_hop 59 | 60 | self.phase_accumulator += self.output_hop * true_freq 61 | 62 | return complex_polarToCartesian(magnitude, self.phase_accumulator) 63 | 64 | def sendFrames(self, frames): 65 | """ 66 | A generator function for processing a group of frames. 67 | 68 | Args: 69 | frames: an array of numpy arrays containing frequency domain information. 70 | 71 | Returns: Each iteration yields the phase correction for the current frame. 72 | """ 73 | for frame in frames: 74 | yield self.sendFrame(frame) 75 | -------------------------------------------------------------------------------- /pitchshifter/pitchshifter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ## 3 | # pitch-shifter-cli.py: Pitch Shifter Command Line Tool 4 | # 5 | # Author(s): Chris Woodall 6 | # MIT License 2015-2021 (c) Chris Woodall 7 | ## 8 | import argparse 9 | import matplotlib.pyplot as pp 10 | import numpy as np 11 | import scipy 12 | import scipy.interpolate 13 | import scipy.io.wavfile 14 | import sys 15 | import logging 16 | 17 | from .stft import * 18 | from .vocoder import * 19 | from .utilities import * 20 | from .resampler import linear_resample 21 | 22 | logging.basicConfig(filename='pitchshifter-cli.log', filemode='w', level=logging.DEBUG) 23 | 24 | 25 | def main(args={}): 26 | # Try to open the wav file and read it 27 | try: 28 | source = scipy.io.wavfile.read(args.source) 29 | except: 30 | print("File {0} does not exist".format(args.source)) 31 | sys.exit(-1) 32 | 33 | RESAMPLING_FACTOR = 2**(args.pitch/12) 34 | HOP = int((1-args.overlap)*args.chunk_size) 35 | HOP_OUT = int(HOP*RESAMPLING_FACTOR) 36 | 37 | audio_samples = source[1].tolist() 38 | 39 | rate = source[0] 40 | mono_samples = stereoToMono(audio_samples) 41 | frames = stft(mono_samples, args.chunk_size, HOP) 42 | vocoder = PhaseVocoder(HOP, HOP_OUT) 43 | adjusted = [frame for frame in vocoder.sendFrames(frames)] 44 | 45 | merged_together = istft(adjusted, args.chunk_size, HOP_OUT) 46 | 47 | if args.no_resample: 48 | final = merged_together 49 | else: 50 | resampled = linear_resample(merged_together, 51 | len(mono_samples)) 52 | final = resampled * args.blend + (1-args.blend) * mono_samples 53 | 54 | if args.debug: 55 | pp.plot(final) 56 | pp.show() 57 | 58 | output = scipy.io.wavfile.write(args.out, rate, np.asarray(final, dtype=np.int16)) 59 | 60 | 61 | def cli(): 62 | parser = argparse.ArgumentParser( 63 | description = "Shifts the pitch of an input .wav file") 64 | parser.add_argument('--source', '-s', help='source .wav file', required=True) 65 | parser.add_argument('--out', '-o', help='output .wav file', required=True) 66 | parser.add_argument('--pitch', '-p', help='pitch shift', default=0, type=float) 67 | parser.add_argument('--blend', '-b', help='blend', default=1, type=float) 68 | parser.add_argument('--chunk-size', '-c', help='chunk size', default=4096, type=int) 69 | parser.add_argument('--overlap', '-e', help='overlap', default=.9, type=float) 70 | parser.add_argument('--debug', '-d', help='debug flag', action="store_true") 71 | parser.add_argument('--no-resample', help='debug flag', action="store_true") 72 | 73 | args = parser.parse_args() 74 | main(args) 75 | 76 | if __name__ == "__main__": 77 | cli() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Pitch Shifter 2 | > Take an input .wav file and shift the pitch without 3 | > changing the speed or length of the input file. 4 | 5 | ## Install 6 | 7 | Require: Python 3.7+ 8 | 9 | ``` 10 | $ git clone https://github.com/cwoodall/pitch-shifter-py.git 11 | $ cd pitch-shifter-py 12 | $ pip install . 13 | ``` 14 | 15 | For development `virtualenv` is recommended: 16 | 17 | ``` 18 | $ virtualenv venv 19 | $ . ./venv/bin/activate 20 | $ pip install . 21 | ``` 22 | 23 | On systems where specific versions of `scipy` and `numpy` might be needed, those should be installed seperately (using `conda` or other means) 24 | 25 | ## Usage 26 | 27 | The following command will shift the tone up an octave (`12` semitones) and blend it so that both have equal volume (`.5`) 28 | 29 | ``` 30 | $ pitchshifter -s ./samples/sample1.wav -o out.wav -p 12 -b .5 31 | ``` 32 | 33 | This example shifts up a fifth (`7` semitones) and blends it so it is all the new shifted version: 34 | 35 | ``` 36 | $ pitchshifter -s ./samples/sample1.wav -o out.wav -p 7 -b 1 37 | ``` 38 | 39 | With some tweaking you can also use this script to slow down and speed up music using the `--no-resample` switch. To 40 | double the speed shift up and octave (`12`), but don't resample: 41 | 42 | ``` 43 | $ pitchshifter -s ./samples/sample1.wav -o out.wav -p 12 --no-resample 44 | ``` 45 | 46 | To half the speed shift down an octave (`-12`), but don't resample: 47 | 48 | ``` 49 | $ pitchshifter -s ./samples/sample1.wav -o out.wav -p -12 --no-resample 50 | ``` 51 | 52 | ## Basic Algorithm Flow 53 | 54 | ``` 55 | Input --> Phase Vocoder (Stretch or compress by 2^(n/12)) --> resample by 2^(n/12) 56 | ``` 57 | # References 58 | 59 | 1. [Guitar Pitch Shifter][grodin1] by François Grondin 60 | 2. [Phase Vocoder Tutorial][dolson1] by Mark Dolson 61 | 3. [NEW PHASE-VOCODER TECHNIQUES FOR PITCH-SHIFTING, HARMONIZING AND OTHER EXOTIC EFFECTS][laroche1] by Laroche and Dolson 62 | 4. [Phase-Vocoder: About this phasing business][laroche2] by Laroche and Dolson 63 | 5. [Phase Locked Vocoder][puckette1] by Puckette 64 | 6. [Improved Phase Vocoder Time-Scale Modification of Audio][laroche3] by Laroche and Dolson 65 | 66 | [grodin1]: http://www.guitarpitchshifter.com 67 | [dolson1]: http://www.eumus.edu.uy/eme/ensenanza/electivas/dsp/presentaciones/PhaseVocoderTutorial.pdf 68 | [laroche1]: http://labrosa.ee.columbia.edu/~dpwe/papers/LaroD99-pvoc.pdf 69 | [laroche2]: http://www.ee.columbia.edu/~dpwe/papers/LaroD97-phasiness.pdf 70 | [puckette1]: http://msp.ucsd.edu/Publications/mohonk95.pdf 71 | [laroche3]: http://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=1&cad=rja&uact=8&ved=0CCQQFjAAahUKEwj-gI_gp9HGAhWKPZIKHafnDTM&url=http%3A%2F%2Fwww.cmap.polytechnique.fr%2F~bacry%2FMVA%2Fgetpapers.php%3Ffile%3Dphase_vocoder.pdf%26type%3Dpdf&ei=qx6gVb7_O4r7yASnz7eYAw&usg=AFQjCNFywrNRdVKK9ZhRpQoRjtn5kP4p_A&sig2=wM7GAqHftEI0yB5z4y5i7w&bvm=bv.97653015,d.aWw 72 | --------------------------------------------------------------------------------