├── .gitignore ├── LICENSE ├── TODO ├── pitcher ├── core.py ├── moogfilter.py └── output_many.py ├── pitcher_cli.py ├── pitcher_gui.py ├── readme.md └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.flac 2 | *.mp3 3 | *.ogg 4 | *.wav 5 | *.asd 6 | *.js 7 | .DS_Store 8 | .vscode 9 | 10 | 11 | # Logs 12 | logs 13 | *.log 14 | 15 | 16 | # Created by https://www.toptal.com/developers/gitignore/api/python 17 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 18 | 19 | ### Python ### 20 | # Byte-compiled / optimized / DLL files 21 | __pycache__/ 22 | *.py[cod] 23 | *$py.class 24 | 25 | # C extensions 26 | *.so 27 | 28 | # Distribution / packaging 29 | .Python 30 | build/ 31 | develop-eggs/ 32 | dist/ 33 | downloads/ 34 | eggs/ 35 | .eggs/ 36 | lib/ 37 | lib64/ 38 | parts/ 39 | sdist/ 40 | var/ 41 | wheels/ 42 | pip-wheel-metadata/ 43 | share/python-wheels/ 44 | *.egg-info/ 45 | .installed.cfg 46 | *.egg 47 | MANIFEST 48 | 49 | # PyInstaller 50 | # Usually these files are written by a python script from a template 51 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 52 | *.manifest 53 | *.spec 54 | 55 | # Installer logs 56 | pip-log.txt 57 | pip-delete-this-directory.txt 58 | 59 | # Unit test / coverage reports 60 | htmlcov/ 61 | .tox/ 62 | .nox/ 63 | .coverage 64 | .coverage.* 65 | .cache 66 | nosetests.xml 67 | coverage.xml 68 | *.cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | 72 | # Translations 73 | *.mo 74 | *.pot 75 | 76 | # Django stuff: 77 | *.log 78 | local_settings.py 79 | db.sqlite3 80 | 81 | # Flask stuff: 82 | instance/ 83 | .webassets-cache 84 | 85 | # Scrapy stuff: 86 | .scrapy 87 | 88 | # Sphinx documentation 89 | docs/_build/ 90 | 91 | # PyBuilder 92 | target/ 93 | 94 | # Jupyter Notebook 95 | .ipynb_checkpoints 96 | 97 | # IPython 98 | profile_default/ 99 | ipython_config.py 100 | 101 | # pyenv 102 | .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # celery beat schedule file 112 | celerybeat-schedule 113 | 114 | # SageMath parsed files 115 | *.sage.py 116 | 117 | # Environments 118 | .env 119 | .venv 120 | env/ 121 | venv/ 122 | ENV/ 123 | env.bak/ 124 | venv.bak/ 125 | 126 | # Spyder project settings 127 | .spyderproject 128 | .spyproject 129 | 130 | # Rope project settings 131 | .ropeproject 132 | 133 | # mkdocs documentation 134 | /site 135 | 136 | # mypy 137 | .mypy_cache/ 138 | .dmypy.json 139 | dmypy.json 140 | 141 | # Pyre type checker 142 | .pyre/ 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/python 145 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 morgan mitchell 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 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | TODO: 2 | - test scipy vs librosa resample, use one consistently or expose as option 3 | - progress bar or some sort of loading indicator 4 | - smaller exe size 5 | - dedicated 33rpm -> 45rpm pre-processing stretch option 6 | - could add moog_output_filter_cutoff slider and/or lp2 cutoff slider to gui 7 | - Android apk 8 | - only use ffmpeg/libav when necessary 9 | - perfect high end input anti aliasing filter fit (close enough, not a priority for now) 10 | -------------------------------------------------------------------------------- /pitcher/core.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # Pitcher v 0.5.2 3 | # Copyright (C) 2020 Morgan Mitchell 4 | # Based on: Physical and Behavioral Circuit Modeling of the SP-12, DT Yeh, 2007 5 | # https://ccrma.stanford.edu/~dtyeh/sp12/yeh2007icmcsp12slides.pdf 6 | 7 | import logging 8 | from sys import platform, path 9 | 10 | import numpy as np 11 | 12 | from scipy.interpolate import interp1d 13 | from scipy.signal import ( ellip, sosfilt, tf2sos, firwin2, decimate, resample, butter ) 14 | from scipy.spatial import cKDTree 15 | 16 | from pydub import AudioSegment 17 | 18 | from soundfile import write as sf_write 19 | 20 | from librosa import load as librosa_load 21 | from librosa.core import resample as librosa_resample 22 | from librosa.util import normalize as librosa_normalize 23 | from librosa.effects import time_stretch as librosa_time_stretch 24 | # TODO: could also try pyrubberband.pyrb.time_stretch 25 | 26 | from moogfilter import MoogFilter 27 | 28 | ZOH_MULTIPLIER = 4 29 | RESAMPLE_MULTIPLIER = 2 30 | 31 | INPUT_SR = 96000 32 | OUTPUT_SR = 48000 33 | 34 | # NOTE: sp-1200 rate 26040, sp-12 rate 27500 35 | SP_SR = 26040 36 | 37 | OUTPUT_FILTER_TYPES = [ 38 | 'lp1', 39 | 'lp2', 40 | 'moog' 41 | ] 42 | 43 | POSITIVE_TUNING_RATIO = 1.02930223664 44 | NEGATIVE_TUNING_RATIOS = { 45 | -1: 1.05652677103003, 46 | -2: 1.1215356033380033, 47 | -3: 1.1834835840896631, 48 | -4: 1.253228360845465, 49 | -5: 1.3310440397149297, 50 | -6: 1.4039714929646099, 51 | -7: 1.5028019735639886, 52 | -8: 1.5766735700797954 53 | } 54 | 55 | log_levels = { 56 | 'INFO': logging.INFO, 57 | 'DEBUG': logging.DEBUG, 58 | 'WARNING': logging.WARNING, 59 | 'ERROR': logging.ERROR, 60 | 'CRITICAL': logging.CRITICAL 61 | } 62 | 63 | log = logging.getLogger(__name__) 64 | sh = logging.StreamHandler() 65 | sh.setFormatter(logging.Formatter('%(levelname)-8s %(message)s')) 66 | log.addHandler(sh) 67 | 68 | 69 | if platform == "darwin": 70 | if not 'ffmpeg' in path: 71 | path.append('/usr/local/bin/ffmpeg') 72 | AudioSegment.converter = '/usr/local/bin/ffmpeg' 73 | 74 | 75 | def calc_quantize_function(quantize_bits): 76 | # https://dspillustrations.com/pages/posts/misc/quantization-and-quantization-noise.html 77 | log.info(f'calculating quantize fn with {quantize_bits} quantize bits') 78 | u = 1 # max amplitude to quantize 79 | quantization_levels = 2 ** quantize_bits 80 | delta_s = 2 * u / quantization_levels # level distance 81 | s_midrise = -u + delta_s / 2 + np.arange(quantization_levels) * delta_s 82 | s_midtread = -u + np.arange(quantization_levels) * delta_s 83 | log.info('done calculating quantize fn') 84 | return s_midrise, s_midtread 85 | 86 | 87 | def adjust_pitch(x, st): 88 | log.info(f'adjusting audio pitch by {st} semitones') 89 | t = 0 90 | if (0 > st >= -8): 91 | t = NEGATIVE_TUNING_RATIOS[st] 92 | elif st > 0: 93 | t = POSITIVE_TUNING_RATIO ** -st 94 | elif st == 0: # no change 95 | return x 96 | else: # -8 > st 97 | # output tuning will loses precision/accuracy the further 98 | # we extrapolate from the device tuning ratios 99 | f = interp1d( 100 | list(NEGATIVE_TUNING_RATIOS.keys()), 101 | list(NEGATIVE_TUNING_RATIOS.values()), 102 | fill_value='extrapolate' 103 | ) 104 | t = f(st) 105 | 106 | n = int(np.round(len(x) * t)) 107 | r = np.linspace(0, len(x) - 1, n).round().astype(np.int32) 108 | pitched = [x[r[e]] for e in range(n-1)] # could yield instead 109 | pitched = np.array(pitched) 110 | log.info('done pitching audio') 111 | 112 | return pitched 113 | 114 | 115 | def filter_input(x): 116 | log.info('applying anti aliasing filter') 117 | # NOTE: Might be able to improve accuracy in the 15 -> 20kHz range with firwin? 118 | # Close already, could perfect it at some point, probably not super important now. 119 | f = ellip(4, 1, 72, 0.666, analog=False, output='sos') 120 | y = sosfilt(f, x) 121 | log.info('done applying anti aliasing filter') 122 | return y 123 | 124 | 125 | def lp1(x, sample_rate): 126 | log.info(f'applying output eq filter {OUTPUT_FILTER_TYPES[0]}') 127 | # follows filter curve shown on slide 3 128 | # cutoff @ 7.5kHz 129 | freq = np.array([0, 6510, 8000, 10000, 11111, 13020, 15000, 17500, 20000, 24000]) 130 | att = np.array([0, 0, -5, -10, -15, -23, -28, -35, -41, -40]) 131 | gain = np.power(10, att/20) 132 | f = firwin2(45, freq, gain, fs=sample_rate, antisymmetric=False) 133 | sos = tf2sos(f, [1.0]) 134 | y = sosfilt(sos, x) 135 | log.info('done applying output eq filter') 136 | return y 137 | 138 | 139 | def lp2(x, sample_rate): 140 | log.info(f'applying output eq filter {OUTPUT_FILTER_TYPES[1]}') 141 | fc = 10000 142 | w = fc / (sample_rate / 2) 143 | sos = butter(7, w, output='sos') 144 | y = sosfilt(sos, x) 145 | log.info('done applying output eq filter') 146 | return y 147 | 148 | 149 | def scipy_resample(y, input_sr, target_sr, factor): 150 | ''' resample from input_sr to target_sr_multiple/factor''' 151 | log.info(f'resampling audio to sample rate of {target_sr * factor}') 152 | seconds = len(y)/input_sr 153 | target_samples = int(seconds * (target_sr * factor)) + 1 154 | resampled = resample(y, target_samples) 155 | log.info('done resample 1/2') 156 | log.info(f'resampling audio to sample rate of {target_sr}') 157 | decimated = decimate(resampled, factor) 158 | log.info('done resample 2/2') 159 | log.info('done resampling audio') 160 | return decimated 161 | 162 | 163 | def zero_order_hold(y, zoh_multiplier): 164 | # NOTE: could also try a freq aliased sinc filter 165 | log.info(f'applying zero order hold of {zoh_multiplier}') 166 | # intentionally oversample by repeating each sample 4 times 167 | zoh_applied = np.repeat(y, zoh_multiplier).astype(np.float32) 168 | log.info('done applying zero order hold') 169 | return zoh_applied 170 | 171 | 172 | def nearest_values(x, y): 173 | x, y = map(np.asarray, (x, y)) 174 | tree = cKDTree(y[:, None]) 175 | ordered_neighbors = tree.query(x[:, None], 1)[1] 176 | return ordered_neighbors 177 | 178 | 179 | def q(x, S, bits): 180 | # NOTE: no audible difference after audacity invert test @ 12 bits 181 | # however, when plotted the scaled amplitude of quantized audio is 182 | # noticeably higher than old implementation, leaving for now 183 | log.info(f'quantizing audio @ {bits} bits') 184 | y = nearest_values(x, S) 185 | quantized = S.flat[y].reshape(x.shape) 186 | log.info('done quantizing') 187 | return quantized 188 | 189 | 190 | # https://stackoverflow.com/questions/53633177/how-to-read-a-mp3-audio-file-into-a-numpy-array-save-a-numpy-array-to-mp3 191 | def write_mp3(f, x, sr): 192 | """numpy array to MP3""" 193 | channels = 2 if (x.ndim == 2 and x.shape[1] == 2) else 1 194 | # zoh converts to float32, when librosa normalized not selected y still within [-1,1] by here 195 | y = np.int16(x * 2 ** 15) 196 | song = AudioSegment(y.tobytes(), frame_rate=sr, sample_width=2, channels=channels) 197 | song.export(f, format="mp3", bitrate="320k") 198 | return 199 | 200 | 201 | def process_array( 202 | y, 203 | st, 204 | input_filter, 205 | quantize, 206 | time_stretch, 207 | output_filter, 208 | quantize_bits, 209 | custom_time_stretch, 210 | output_filter_type, 211 | moog_output_filter_cutoff 212 | ): 213 | 214 | log.info('done loading') 215 | 216 | 217 | if input_filter: 218 | y = filter_input(y) 219 | else: 220 | log.info('skipping input anti aliasing filter') 221 | 222 | resampled = scipy_resample(y, INPUT_SR, SP_SR, RESAMPLE_MULTIPLIER) 223 | 224 | if quantize: 225 | # TODO: expose midrise option? 226 | # simulate analog -> digital conversion 227 | midrise, midtread = calc_quantize_function(quantize_bits) 228 | resampled = q(resampled, midtread, quantize_bits) 229 | else: 230 | log.info('skipping quantize') 231 | 232 | pitched = adjust_pitch(resampled, st) 233 | 234 | if ((custom_time_stretch == 1.0) and (time_stretch == True)): 235 | # Default SP-12 timestretch inherent w/ adjust_pitch 236 | pass 237 | elif ((custom_time_stretch == 0.0) or (time_stretch == False)): 238 | # No timestretch (e.g. original audio length): 239 | rate = len(pitched) / len(resampled) 240 | log.info('time stretch: stretching back to original length...') 241 | pitched = librosa_time_stretch(pitched, rate=rate) 242 | pass 243 | else: 244 | # Custom timestretch 245 | rate = len(pitched) / len(resampled) 246 | log.info('time stretch: stretching back to original length...') 247 | pitched = librosa_time_stretch(pitched, rate=rate) 248 | log.info(f'running custom time stretch of rate: {custom_time_stretch}') 249 | pitched = librosa_time_stretch(pitched, rate=custom_time_stretch) 250 | 251 | # oversample again (default factor of 4) to simulate ZOH 252 | post_zero_order_hold = zero_order_hold(pitched, ZOH_MULTIPLIER) 253 | 254 | # NOTE: why use scipy above and librosa here? 255 | # check git history to see if there was a note about this 256 | output = librosa_resample( 257 | np.asfortranarray(post_zero_order_hold), 258 | orig_sr=SP_SR * ZOH_MULTIPLIER, 259 | target_sr=OUTPUT_SR 260 | ) 261 | 262 | if output_filter: 263 | if output_filter_type == OUTPUT_FILTER_TYPES[0]: 264 | # lp eq filter cutoff @ 7.5kHz, SP outputs 3 & 4 265 | output = lp1(output, OUTPUT_SR) 266 | elif output_filter_type == OUTPUT_FILTER_TYPES[1]: 267 | # lp eq filter cutoff @ 10kHz, SP outputs 5 & 6 268 | output = lp2(output, OUTPUT_SR) 269 | else: 270 | # moog vcf approximation, SP outputs 1 & 2 originally used for kicks 271 | mf = MoogFilter(sample_rate=OUTPUT_SR, cutoff=moog_output_filter_cutoff) 272 | output = mf.process(output) 273 | else: 274 | # unfiltered like outputs 7 & 8 275 | log.info('skipping output eq filter') 276 | 277 | return output 278 | 279 | 280 | def write_audio(output, output_file_path, normalize_output): 281 | 282 | log.info(f'writing {output_file_path}, at sample rate {OUTPUT_SR} with normalize_output set to {normalize_output}') 283 | 284 | if normalize_output: 285 | output = librosa_normalize(output) 286 | 287 | if '.mp3' in output_file_path: 288 | write_mp3(output_file_path, output, OUTPUT_SR) 289 | elif '.wav' in output_file_path: 290 | sf_write(output_file_path, output, OUTPUT_SR, subtype='PCM_16') 291 | elif '.ogg' in output_file_path: 292 | sf_write(output_file_path, output, OUTPUT_SR, format='ogg', subtype='vorbis') 293 | elif '.flac' in output_file_path: 294 | sf_write(output_file_path, output, OUTPUT_SR, format='flac', subtype='PCM_16') 295 | else: 296 | log.error(f'Output file type unsupported or unrecognized, saving to {output_file_path}.wav') 297 | sf_write(output_file_path + '.wav', output, OUTPUT_SR, subtype='PCM_16') 298 | 299 | log.info(f'done writing output_file_path at: {output_file_path}') 300 | return 301 | 302 | 303 | def pitch( 304 | st: int, 305 | input_file_path: str, 306 | output_file_path: str, 307 | log_level: str, 308 | input_filter=True, 309 | quantize=True, 310 | time_stretch=True, 311 | output_filter=True, 312 | normalize_output=False, 313 | quantize_bits=12, 314 | custom_time_stretch=1.0, 315 | output_filter_type=OUTPUT_FILTER_TYPES[0], 316 | moog_output_filter_cutoff=10000, 317 | force_mono=False, 318 | input_data=None # allows passing an array to avoid re-processing input for output_many.py 319 | ): 320 | 321 | valid_levels = list(log_levels.keys()) 322 | if (not log_level) or (log_level.upper() not in valid_levels): 323 | log.warn(f'Invalid log-level: "{log_level}", log-level set to "INFO", ' 324 | f'valid log levels are {valid_levels}') 325 | log_level = 'INFO' 326 | 327 | log_level = log_levels[log_level] 328 | log.setLevel(log_level) 329 | 330 | if output_filter_type not in OUTPUT_FILTER_TYPES: 331 | log.error(f'invalid output_filter_type {output_filter_type}, valid values are {OUTPUT_FILTER_TYPES}') 332 | log.error(f'using output_filter_type {OUTPUT_FILTER_TYPES[0]}') 333 | 334 | y = None 335 | if input_data is not None: 336 | # if provided, use already processed input file data 337 | y = input_data 338 | else: 339 | # otherwise process the file at intput_file_path 340 | log.info(f'loading: "{input_file_path}" at oversampled rate: {INPUT_SR}') 341 | y, s = librosa_load(input_file_path, sr=INPUT_SR, mono=force_mono) 342 | 343 | if y.ndim == 2: # stereo 344 | y1 = y[0] 345 | y2 = y[1] 346 | 347 | log.info('processing stereo channels seperately') 348 | log.info('processing channel 1') 349 | y1 = process_array( 350 | y1, st, input_filter, quantize, time_stretch, output_filter, quantize_bits, 351 | custom_time_stretch, output_filter_type, moog_output_filter_cutoff 352 | ) 353 | log.info('processing channel 2') 354 | y2 = process_array( 355 | y2, st, input_filter, quantize, time_stretch, output_filter, quantize_bits, 356 | custom_time_stretch, output_filter_type, moog_output_filter_cutoff 357 | ) 358 | y = np.hstack((y1.reshape(-1, 1), y2.reshape(-1,1))) 359 | write_audio(y, output_file_path, normalize_output) 360 | else: # mono 361 | y = process_array( 362 | y, st, input_filter, quantize, time_stretch, output_filter, quantize_bits, 363 | custom_time_stretch, output_filter_type, moog_output_filter_cutoff 364 | ) 365 | write_audio(y, output_file_path, normalize_output) 366 | -------------------------------------------------------------------------------- /pitcher/moogfilter.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Copyright 2012 Stefano D'Angelo 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | THIS SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 7 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 8 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 9 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 10 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 11 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 12 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 13 | */ 14 | ''' 15 | 16 | from numpy import tanh 17 | 18 | 19 | # Based on the Improved Model from ddiakopoulos' MoogLadders repo 20 | # https://github.com/ddiakopoulos/MoogLadders 21 | 22 | # The Improved Model itself was created by D'Angelo & Valimaki in 2013's 23 | # "An Improved Virtual Analog Model of the Moog Ladder Filter" 24 | # (https://raw.githubusercontent.com/ddiakopoulos/MoogLadders/master/research/DAngeloValimaki.pdf) 25 | 26 | 27 | # "Two of these filters employ the SSM-2044 Voltage Controlled Filter (VCF) chip as a 4-pole lowpass with time-varying cutoff frequency." 28 | # VCF functionality only used for outputs 1 & 2 29 | 30 | # could test against slides and match sp-1200's use of ssm2044 for outputs 1&2 31 | # - Exponential time constant = 0.085 s 32 | # - Initial Fc = 14150 33 | # - Final Fc = 1150 34 | 35 | # for now just exposing cutoff through pitcher options 36 | # since mostly using for general audio not just kicks 37 | 38 | 39 | # Thermal voltage (26 miliwatts at room temp) 40 | VT = 0.312 41 | MOOG_PI = 3.14159265358979323846264338327950288 42 | 43 | class LadderFilterBase: 44 | def __init__(self, sample_rate, cutoff=0, resonance=0): 45 | self.sample_rate = sample_rate 46 | self.cutoff = cutoff 47 | # should likely put limits on this (ie 4>res>0) 48 | self.resonance = resonance 49 | return 50 | 51 | def process(self, samples): 52 | return samples 53 | 54 | def getResonance(self): 55 | return self.resonance 56 | 57 | def getCutoff(self): 58 | return self.cutoff 59 | 60 | def setResonance(self, res): 61 | self.resonance = res 62 | 63 | def setCutoff(self, cutoff): 64 | self.cutoff = cutoff 65 | 66 | 67 | class MoogFilter(LadderFilterBase): 68 | def __init__(self, sample_rate=48000, cutoff=10000, resonance=0.1, drive=1.0): 69 | self.sample_rate = sample_rate 70 | self.resonance = resonance 71 | self.drive = drive 72 | self.x = 0 73 | self.g = 0 74 | self.V = [0,0,0,0] 75 | self.dV = [0,0,0,0] 76 | self.tV = [0,0,0,0] 77 | self.setCutoff(cutoff) 78 | 79 | def process(self, samples): 80 | dV0 = 0 81 | dV1 = 0 82 | dV2 = 0 83 | dV3 = 0 84 | 85 | for i, s in enumerate(samples): 86 | dV0 = -self.g * (tanh((self.drive * samples[i] + self.resonance * self.V[3]) / (2.0 * VT)) + self.tV[0]) 87 | self.V[0] += (dV0 + self.dV[0]) / (2.0 * self.sample_rate) 88 | self.dV[0] = dV0 89 | self.tV[0] = tanh(self.V[0] / (2.0 * VT)) 90 | 91 | dV1 = self.g * (self.tV[0] - self.tV[1]) 92 | self.V[1] += (dV1 + self.dV[1]) / (2.0 * self.sample_rate) 93 | self.dV[1] = dV1 94 | self.tV[1] = tanh(self.V[1] / (2.0 * VT)) 95 | 96 | dV2 = self.g * (self.tV[1] - self.tV[2]) 97 | self.V[2] += (dV2 + self.dV[2]) / (2.0 * self.sample_rate) 98 | self.dV[2] = dV2 99 | self.tV[2] = tanh(self.V[2] / (2.0 * VT)) 100 | 101 | dV3 = self.g * (self.tV[2] - self.tV[3]) 102 | self.V[3] += (dV3 + self.dV[3]) / (2.0 * self.sample_rate) 103 | self.dV[3] = dV3 104 | self.tV[3] = tanh(self.V[3] / (2.0 * VT)) 105 | 106 | samples[i] = self.V[3] 107 | 108 | return samples 109 | 110 | def setCutoff(self, cutoff): 111 | self.cutoff = cutoff 112 | self.x = (MOOG_PI * cutoff) / self.sample_rate 113 | self.g = 4.0 * MOOG_PI * VT * cutoff * (1.0 - self.x) / (1.0 + self.x) 114 | -------------------------------------------------------------------------------- /pitcher/output_many.py: -------------------------------------------------------------------------------- 1 | from core import pitch, INPUT_SR 2 | 3 | import click 4 | from librosa import load 5 | from pathlib import Path 6 | 7 | # - could move this into core 8 | # - would need to change core.py's st to an array 9 | # - would also need to accomodate output_path - core expects a file path, not dir 10 | 11 | def output_many(input_file, output_dir, up): 12 | in_file_name = Path(input_file).stem 13 | in_file_type = Path(input_file).suffix 14 | 15 | output_path = Path(output_dir) 16 | 17 | # pitch down 12 times, one per st drop 18 | OUTPUT_MANY_ST_RANGE = [x for x in range(-12, 0)] 19 | 20 | if up: 21 | # alternatively pitch up 22 | OUTPUT_MANY_ST_RANGE = [x for x in range(1, 12)] 23 | 24 | 25 | if not output_path.exists(): 26 | output_path.mkdir() 27 | 28 | if not output_path.is_dir(): 29 | raise ValueError(f'output-dir should be a directory, received: {output_dir}') 30 | 31 | input_file, s = load(input_file, sr=INPUT_SR, mono=False) 32 | 33 | for st in OUTPUT_MANY_ST_RANGE: 34 | output_file = f'{in_file_name}_{st}{in_file_type}' 35 | pitch(st=st, input_file_path='unused', output_file_path=str(output_path.joinpath(output_file)), log_level='INFO', input_data=input_file) 36 | 37 | return 38 | 39 | 40 | @click.command() 41 | @click.option('--input-file', type=str, required=True) 42 | @click.option('--output-dir', type=str, required=True) 43 | @click.option('--up', is_flag=True, default=False) 44 | def wrapper(input_file, output_dir, up): 45 | output_many(input_file, output_dir, up) 46 | return 47 | 48 | 49 | if __name__ == "__main__": 50 | wrapper() -------------------------------------------------------------------------------- /pitcher_cli.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import click 4 | from pitcher.core import pitch, OUTPUT_FILTER_TYPES 5 | 6 | 7 | @click.command() 8 | @click.option('--st', type=int, default=0, help='number of semitones to shift') 9 | @click.option('--input-file', type=str, required=True) 10 | @click.option('--output-file', type=str, required=True) 11 | @click.option('--log-level', type=str, default='INFO') 12 | @click.option('--input-filter', is_flag=True, default=True) 13 | @click.option('--quantize', is_flag=True, default=True) 14 | @click.option('--time-stretch', is_flag=True, default=True) 15 | @click.option('--output-filter', is_flag=True, default=True) 16 | @click.option('--normalize-output', is_flag=True, default=False) 17 | @click.option('--quantize-bits', type=int, default=12, help='bit rate of quantized output') 18 | @click.option('--custom-time-stretch', type=float, default=1.0) 19 | @click.option('--output-filter-type', type=click.Choice(OUTPUT_FILTER_TYPES), default=OUTPUT_FILTER_TYPES[0]) 20 | @click.option('--moog-output-filter-cutoff', type=int, default=10000) 21 | @click.option('--force-mono', is_flag=True, default=False) 22 | def cli_wrapper( 23 | st, 24 | input_file, 25 | output_file, 26 | log_level, 27 | input_filter, 28 | quantize, 29 | time_stretch, 30 | output_filter, 31 | normalize_output, 32 | quantize_bits, 33 | custom_time_stretch, 34 | output_filter_type, 35 | moog_output_filter_cutoff, 36 | force_mono 37 | ): 38 | 39 | pitch( 40 | st=st, 41 | input_file_path=input_file, 42 | output_file_path=output_file, 43 | log_level=log_level, 44 | input_filter=input_filter, 45 | quantize=quantize, 46 | time_stretch=time_stretch, 47 | output_filter=output_filter, 48 | normalize_output=normalize_output, 49 | quantize_bits=quantize_bits, 50 | custom_time_stretch=custom_time_stretch, 51 | output_filter_type=output_filter_type, 52 | moog_output_filter_cutoff=moog_output_filter_cutoff, 53 | force_mono=force_mono 54 | ) 55 | return 56 | 57 | 58 | 59 | if __name__ == '__main__': 60 | cli_wrapper() 61 | -------------------------------------------------------------------------------- /pitcher_gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from tkinter import ( 4 | Button, DoubleVar, IntVar, END, Entry, Entry, filedialog, Label, Scale, Tk, 5 | Checkbutton, Frame, StringVar, OptionMenu 6 | ) 7 | 8 | from pitcher.core import pitch, OUTPUT_FILTER_TYPES 9 | 10 | 11 | def gui(): 12 | window = Tk() 13 | window.geometry('600x400') 14 | window.resizable(True, False) 15 | window.title('Pitcher') 16 | 17 | current_st_value = DoubleVar() 18 | current_bit_value = DoubleVar() 19 | current_time_stretch_value = DoubleVar() 20 | 21 | current_st_value.set(0) 22 | current_bit_value.set(12) 23 | current_time_stretch_value.set(1.0) 24 | 25 | def get_current_st_value(): 26 | return int(current_st_value.get()) 27 | 28 | def get_current_bit_value(): 29 | return int(current_bit_value.get()) 30 | 31 | def get_current_time_stretch_value(): 32 | return current_time_stretch_value.get() 33 | 34 | top_frame = Frame(window) 35 | middle_frame = Frame(window) 36 | 37 | top_frame.pack(side='top', fill='x') 38 | middle_frame.pack(side='top', fill='x') 39 | 40 | # sliders 41 | st_frame = Frame(top_frame) 42 | st_frame.pack(anchor='nw', side='left', padx=10, pady=10) 43 | 44 | bit_frame = Frame(top_frame) 45 | bit_frame.pack(anchor='nw', side='left', padx=10, pady=10) 46 | 47 | stretch_frame = Frame(top_frame) 48 | stretch_frame.pack(anchor='nw', side='left', padx=10, pady=10) 49 | 50 | st_slider_label = Label(st_frame, text='Semitones:') 51 | st_slider_label.pack(side='top') 52 | 53 | st_slider = Scale( 54 | st_frame, 55 | from_= 12, 56 | to=-12, 57 | orient='vertical', 58 | tickinterval=1, 59 | length=200, 60 | variable=current_st_value 61 | ) 62 | st_slider.pack() 63 | 64 | bit_slider_label = Label(bit_frame, text='Quantize Bits:') 65 | bit_slider_label.pack(side='top') 66 | 67 | bit_slider = Scale( 68 | bit_frame, 69 | from_= 16, 70 | to = 2, 71 | orient='vertical', 72 | length = 200, 73 | tickinterval=2, 74 | variable=current_bit_value 75 | ) 76 | bit_slider.pack() 77 | 78 | stretch_slider_label = Label(stretch_frame, text='Time Stretch:') 79 | stretch_slider_label.pack(side='top') 80 | 81 | stretch_slider = Scale( 82 | stretch_frame, 83 | from_= 5, 84 | to = 0.05, 85 | orient='vertical', 86 | length = 200, 87 | resolution=0.01, 88 | variable=current_time_stretch_value 89 | ) 90 | stretch_slider.pack() 91 | 92 | # other options 93 | 94 | input_filter = IntVar(value=1) 95 | quantize = IntVar(value=1) 96 | output_filter = IntVar(value=1) 97 | time_stretch = IntVar(value=1) 98 | normalize_output = IntVar(value=0) 99 | force_mono = IntVar(value=0) 100 | output_filter_type = StringVar(value=OUTPUT_FILTER_TYPES[0]) 101 | 102 | o_frame = Frame(top_frame) 103 | o_frame.pack(padx=20, pady=10) 104 | 105 | input_filter_button = Checkbutton(o_frame, text="Input Filter", variable=input_filter) 106 | quantize_button = Checkbutton(o_frame, text="Quantize", variable=quantize) 107 | normalize_output_button = Checkbutton(o_frame, text="Normalize Output", variable=normalize_output) 108 | time_stretch_button = Checkbutton(o_frame, text="Time Stretch", variable=time_stretch) 109 | output_filter_button = Checkbutton(o_frame, text="Output Filter", variable=output_filter) 110 | force_mono_button = Checkbutton(o_frame, text="Force Mono", variable=force_mono) 111 | 112 | oft_label = Label(o_frame, text='Output Filter Type:') 113 | output_filter_menu = OptionMenu(o_frame, output_filter_type, *OUTPUT_FILTER_TYPES) 114 | 115 | oft_label.pack() 116 | output_filter_menu.pack() 117 | 118 | input_filter_button.pack() 119 | quantize_button.pack() 120 | normalize_output_button.pack() 121 | time_stretch_button.pack() 122 | output_filter_button.pack() 123 | force_mono_button.pack() 124 | 125 | # file input/output 126 | 127 | io_frame = Frame(middle_frame) 128 | io_frame.pack(fill='x') 129 | 130 | i_frame = Frame(io_frame) 131 | o_frame = Frame(io_frame) 132 | 133 | i_frame.pack(fill='both', side='top', padx=20, pady=10) 134 | o_frame.pack(fill='x', padx=20, pady=10) 135 | 136 | input_entry = Entry(i_frame, width=60) 137 | output_entry = Entry(o_frame, width=60) 138 | 139 | def askopeninputfilename(): 140 | input_file = filedialog.askopenfilename(filetypes=[("audio files", "*.mp3 *.wav *.flac")], parent=i_frame, title='Choose a file') 141 | input_entry.delete(0, END) 142 | input_entry.insert(0, input_file) 143 | 144 | def askopenoutputfilename(): 145 | output_file = filedialog.asksaveasfilename(filetypes=[("audio files", "*.mp3 *.wav *.flac")], parent=o_frame, title='Choose a file') 146 | output_entry.delete(0, END) 147 | output_entry.insert(0, output_file) 148 | 149 | input_browse_button = Button(i_frame, text='Input File', command=askopeninputfilename, width=16) 150 | output_browse_button = Button(o_frame, text='Output File', command=askopenoutputfilename, width=16) 151 | 152 | input_browse_button.pack(side='left') 153 | input_entry.pack(side='top') 154 | 155 | output_browse_button.pack(side='left') 156 | output_entry.pack() 157 | 158 | run_button = Button( 159 | window, 160 | text='Pitch', 161 | command= lambda: pitch( 162 | st=get_current_st_value(), 163 | input_file_path=input_entry.get(), 164 | output_file_path=output_entry.get(), 165 | log_level='INFO', 166 | input_filter=bool(input_filter.get()), 167 | quantize=bool(quantize.get()), 168 | time_stretch=bool(time_stretch.get()), 169 | normalize_output=bool(normalize_output.get()), 170 | output_filter=bool(output_filter.get()), 171 | quantize_bits=get_current_bit_value(), 172 | custom_time_stretch=get_current_time_stretch_value(), 173 | output_filter_type=output_filter_type.get(), 174 | moog_output_filter_cutoff=10000, 175 | force_mono=bool(force_mono.get()) 176 | ) 177 | ) 178 | 179 | run_button.pack() 180 | window.mainloop() 181 | 182 | 183 | if __name__ == '__main__': 184 | gui() -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Pitcher.py 2 | 3 | Screen Shot 2022-11-14 at 8 09 32 PM 4 | 5 | 6 | - Free & OS emulation of the SP-12 & SP-1200 signal chain (now with GUI) 7 | - Pitch shift / bitcrush / resample audio files 8 | - Written and tested in Python v3.10.7 on Windows 10 & MacOS Mojave 10.14.6 9 | - Based on [Physical and Behavioral Circuit Modeling of the SP-12 10 | Sampler, DT Yeh, 2007](https://ccrma.stanford.edu/~dtyeh/papers/yeh07_icmc_sp12.pdf) & [Slides](https://ccrma.stanford.edu/~dtyeh/sp12/yeh2007icmcsp12slides.pdf) 11 | - Audio examples [here](https://soundcloud.com/user-320158268/sets/pitcher-examples) and [here](https://tinyurl.com/yckcmhb2) 12 | 13 | ### Installation 14 | ``` 15 | 1. Use git to clone this repo, or download it as a ZIP using the "Clone or download" button & unzip 16 | 2. Open your terminal of choice 17 | 3. cd to the new pitcher directory 18 | 4. pip install -r ./requirements.txt 19 | ``` 20 | 21 | ### Usage: 22 | ``` 23 | python pitcher_cli.py --input-file ./input.wav --st -4 --output-file ./output.wav 24 | ``` 25 | 26 | You can now also run a simple gui version using the command: 27 | 28 | ```python pitcher_gui.py``` 29 | 30 | 31 | The [releases page](https://github.com/mwcm/pitcher/releases/tag/0.5.2) also has binary files for the GUI (.exe and .app). 32 | 33 | 34 | ### Options: 35 | 36 | ``` 37 | --st - number of semitones to shift pitch by, int, required 38 | --input-file - path to input file, string, required 39 | --output-file - path to output file, string, required 40 | --log-level - sets logging threshold, string, default 'INFO' 41 | --input-filter - input anti aliasing low pass filter, flag, default True 42 | --quantize - simulate ADC quantize, flag, default True 43 | --time-stretch - enable or disable time_shift entirely, flag, default True 44 | --output-filter - skip all output filtering (default and moog), flag, default True 45 | --normalize-output - normalize output volume to , flag, default False 46 | --quantize-bits - bit rate of quantized output, int, default 12 47 | --custom-time-stretch - custom shift, 1.0 for device default, 0.0 for none, float, default 1.0 48 | --output-filter-type - 'lp1', 'lp2' or 'moog' str, default 'lp1' 49 | lp1 cutoff = 7.5kHz, lp2 cutoff = 10kHz, moog=10kHz 50 | --moog-output-filter-cutoff - set cutoff for moog SSM2044 approximation, int, default 10000 51 | --force-mono - convert input to mono, ouput will also be mono, flag, default False 52 | ``` 53 | 54 | If you find this project useful, please consider donating to the [NAACP Legal Defense Fund](https://engage.naacpldf.org/dBCvDTd9IEiXX_jPkmkT_w2) or [BLM CA](https://www.blacklivesmatter.ca/) 55 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwcm/pitcher/847ad1e0bc9c0549ee5c4ca9f4797e175219e55c/requirements.txt --------------------------------------------------------------------------------