├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── lib ├── __init__.py ├── audio_helpers.py ├── compare.py ├── constants.py ├── deflac.py ├── flacize.py ├── graph.py ├── group_velcurves.py ├── loop.py ├── map_xfvel.py ├── midi_helpers.py ├── numpy_helpers.py ├── pitch.py ├── quantize.py ├── record.py ├── send_notes.py ├── sfzparser.py ├── spectrogram.py ├── starts_with_click.py ├── truncate.py ├── utils.py ├── volume_leveler.py └── wavio.py ├── record.py ├── requirements.txt ├── samplescanner └── tests ├── note_name_test.py ├── pitch_test.py ├── readme_test.py └── utils_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # Various 99 | *.pdf 100 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | # command to install dependencies 5 | install: "pip install -r requirements.txt" 6 | # command to run tests 7 | script: 8 | - python -m pytest tests/ 9 | # Run pep8 on all .py files in all subfolders 10 | # (Ignore "E402: module level import not at top of file") 11 | - find . -name \*.py -exec pep8 --ignore=E402 {} + 12 | addons: 13 | apt: 14 | packages: 15 | # required to pip install rtmidi and pyaudio 16 | - libasound2-dev 17 | - libjack-jackd2-dev 18 | - portaudio19-dev 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2018 Peter Sobot https://petersobot.com github@petersobot.com 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SampleScanner 2 | [![Build Status](https://travis-ci.org/psobot/SampleScanner.svg?branch=master)](https://travis-ci.org/psobot/SampleScanner) 3 | 4 | ![SampleScanner Logo](https://cloud.githubusercontent.com/assets/213293/24964018/1dcb4092-1f6e-11e7-8b3b-47704e6c8aeb.png) 5 | 6 | 7 | SampleScanner is a command-line tool to turn MIDI instruments (usually hardware) into virtual (software) instruments automatically. It's similar to [Redmatica's now-discontinued _AutoSampler_](http://www.soundonsound.com/reviews/redmatica-autosampler) software (now part of Apple's [MainStage](https://441k.com/sampling-synths-with-auto-sampler-in-mainstage-3-412deb8f900e)), but open-source and cross-platform. 8 | 9 | ## Features 10 | 11 | - Uses native system integration (via `rtmidi` and `pyAudio`) for compatibility with all audio and MIDI devices 12 | - Outputs to the open-source [sfz 2.0 sample format](http://ariaengine.com/overview/sfz-format/), playable by [Sforzando](https://www.plogue.com/products/sforzando/) (and others) 13 | - Optional FLAC compression (on by default) to reduce sample set file size by up to 75% 14 | - Flexible configuration options and extensive command-line interface 15 | - Experimental looping algorithm to extend perpetual samples 16 | - Clipping detection at sample time 17 | - 100% Python to enable cross-platform compatibility 18 | - Has been known to work in Windows, Mac OS and Linux 19 | 20 | ## Installation 21 | 22 | Requires a working `python` (version 2.7), `pip`, and `ffmpeg` to be installed on the system. 23 | 24 | ``` 25 | git clone git@github.com:psobot/SampleScanner 26 | cd SampleScanner 27 | pip install -r requirements.txt 28 | ``` 29 | 30 | ## How to run 31 | 32 | Run `./samplescanner -h` for a full argument listing: 33 | 34 | ```contentsof 35 | usage: samplescanner [-h] [--cc-before [CC_BEFORE [CC_BEFORE ...]]] 36 | [--cc-after [CC_AFTER [CC_AFTER ...]]] 37 | [--program-number PROGRAM_NUMBER] [--low-key LOW_KEY] 38 | [--high-key HIGH_KEY] 39 | [--velocity-levels VELOCITY_LEVELS [VELOCITY_LEVELS ...]] 40 | [--key-skip KEY_RANGE] [--max-attempts MAX_ATTEMPTS] 41 | [--limit LIMIT] [--has-portamento] [--sample-asc] 42 | [--no-flac] [--no-delete] [--loop] 43 | [--midi-port-name MIDI_PORT_NAME] 44 | [--midi-port-index MIDI_PORT_INDEX] 45 | [--midi-channel MIDI_CHANNEL] 46 | [--audio-interface-name AUDIO_INTERFACE_NAME] 47 | [--audio-interface-index AUDIO_INTERFACE_INDEX] 48 | [--sample-rate SAMPLE_RATE] [--print-progress] 49 | output_folder 50 | 51 | create SFZ files from external audio devices 52 | 53 | optional arguments: 54 | -h, --help show this help message and exit 55 | 56 | Sampling Options: 57 | --cc-before [CC_BEFORE [CC_BEFORE ...]] 58 | Send MIDI CC before the program change. Put comma 59 | between CC# and value. Example: --cc 0,127 "64,65" 60 | --cc-after [CC_AFTER [CC_AFTER ...]] 61 | Send MIDI CC after the program change. Put comma 62 | between CC# and value. Example: --cc 0,127 "64,65" 63 | --program-number PROGRAM_NUMBER 64 | switch to a program number before recording 65 | --low-key LOW_KEY key to start sampling from (key name, octave number) 66 | --high-key HIGH_KEY key to stop sampling at (key name, octave number) 67 | --velocity-levels VELOCITY_LEVELS [VELOCITY_LEVELS ...] 68 | velocity levels (in [1, 127]) to sample 69 | --key-skip KEY_RANGE number of keys covered by one sample 70 | --max-attempts MAX_ATTEMPTS 71 | maximum number of tries to resample a note 72 | --limit LIMIT length in seconds of longest sample 73 | --has-portamento play each note once before sampling to avoid 74 | portamento sweeps between notes 75 | --sample-asc sample notes from low to high (default false) 76 | 77 | Output Options: 78 | output_folder name of output folder 79 | --no-flac don't compress output to flac samples 80 | --no-delete leave temporary .aif files in place after flac 81 | compression 82 | --loop attempt to loop sounds (should only be used with 83 | sounds with infinite sustain) 84 | 85 | MIDI/Audio IO Options: 86 | --midi-port-name MIDI_PORT_NAME 87 | name of MIDI device to use 88 | --midi-port-index MIDI_PORT_INDEX 89 | index of MIDI device to use 90 | --midi-channel MIDI_CHANNEL 91 | MIDI channel to send messages on 92 | --audio-interface-name AUDIO_INTERFACE_NAME 93 | name of audio input device to use 94 | --audio-interface-index AUDIO_INTERFACE_INDEX 95 | index of audio input device to use 96 | --sample-rate SAMPLE_RATE 97 | sample rate to use. audio interface must support this 98 | rate. 99 | 100 | Misc Options: 101 | --print-progress show text-based VU meters in terminal (default false, 102 | can cause audio artifacts) 103 | ``` 104 | 105 | ## Contributors, Copyright and License 106 | 107 | tl;dr: SampleScanner is © 2015-2018 [Peter Sobot](https://petersobot.com), and released under the MIT License. 108 | Many contributors have helped improve SampleScanner, including: 109 | 110 | - [Nando Florestan](https://github.com/nandoflorestan) 111 | - [Mike Verdone](https://github.com/sixohsix) 112 | 113 | ```contentsof 114 | The MIT License 115 | 116 | Copyright (c) 2015-2018 Peter Sobot https://petersobot.com github@petersobot.com 117 | 118 | Permission is hereby granted, free of charge, to any person obtaining a copy 119 | of this software and associated documentation files (the "Software"), to deal 120 | in the Software without restriction, including without limitation the rights 121 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 122 | copies of the Software, and to permit persons to whom the Software is 123 | furnished to do so, subject to the following conditions: 124 | 125 | The above copyright notice and this permission notice shall be included in 126 | all copies or substantial portions of the Software. 127 | 128 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 129 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 130 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 131 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 132 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 133 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 134 | THE SOFTWARE. 135 | ``` 136 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/psobot/SampleScanner/a95f694ad326bf0c7dda580f62dd38767c0a9754/lib/__init__.py -------------------------------------------------------------------------------- /lib/audio_helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | import numpy 3 | from utils import note_name, percent_to_db 4 | from record import record 5 | from constants import CLIPPING_THRESHOLD, \ 6 | CLIPPING_CHECK_NOTE, \ 7 | EXIT_ON_CLIPPING, \ 8 | SAMPLE_RATE 9 | from midi_helpers import all_notes_off, CHANNEL_OFFSET 10 | 11 | 12 | def generate_sample( 13 | limit, 14 | midiout, 15 | note, 16 | velocity, 17 | midi_channel, 18 | threshold, 19 | print_progress=False, 20 | audio_interface_name=None, 21 | sample_rate=SAMPLE_RATE, 22 | ): 23 | all_notes_off(midiout, midi_channel) 24 | 25 | def after_start(): 26 | midiout.send_message([ 27 | CHANNEL_OFFSET + midi_channel, note, velocity 28 | ]) 29 | 30 | def on_time_up(): 31 | midiout.send_message([ 32 | CHANNEL_OFFSET + midi_channel, note, 0 33 | ]) 34 | return True # Get the release after keyup 35 | 36 | return record( 37 | limit=limit, 38 | after_start=after_start, 39 | on_time_up=on_time_up, 40 | threshold=threshold, 41 | print_progress=print_progress, 42 | audio_interface_name=audio_interface_name, 43 | sample_rate=sample_rate, 44 | ) 45 | 46 | 47 | def sample_threshold_from_noise_floor(bit_depth, audio_interface_name): 48 | time.sleep(1) 49 | print "Sampling noise floor..." 50 | sample_width, data, release_time = record( 51 | limit=2.0, 52 | after_start=None, 53 | on_time_up=None, 54 | threshold=0.1, 55 | print_progress=True, 56 | allow_empty_return=True, 57 | audio_interface_name=audio_interface_name, 58 | ) 59 | noise_floor = ( 60 | numpy.amax(numpy.absolute(data)) / 61 | float(2 ** (bit_depth - 1)) 62 | ) 63 | print "Noise floor has volume %8.8f dBFS" % percent_to_db(noise_floor) 64 | threshold = noise_floor * 1.1 65 | print "Setting threshold to %8.8f dBFS" % percent_to_db(threshold) 66 | return threshold 67 | 68 | 69 | def check_for_clipping( 70 | midiout, 71 | midi_channel, 72 | threshold, 73 | bit_depth, 74 | audio_interface_name, 75 | ): 76 | time.sleep(1) 77 | print "Checking for clipping and balance on note %s..." % ( 78 | note_name(CLIPPING_CHECK_NOTE) 79 | ) 80 | 81 | sample_width, data, release_time = generate_sample( 82 | limit=2.0, 83 | midiout=midiout, 84 | note=CLIPPING_CHECK_NOTE, 85 | velocity=127, 86 | midi_channel=midi_channel, 87 | threshold=threshold, 88 | print_progress=True, 89 | audio_interface_name=audio_interface_name, 90 | ) 91 | 92 | if data is None: 93 | raise Exception( 94 | "Can't check for clipping because all we recorded was silence.") 95 | 96 | max_volume = ( 97 | numpy.amax(numpy.absolute(data)) / 98 | float(2 ** (bit_depth - 1)) 99 | ) 100 | 101 | # All notes off, but like, a lot, again 102 | for _ in xrange(0, 2): 103 | all_notes_off(midiout, midi_channel) 104 | 105 | print "Maximum volume is around %8.8f dBFS" % percent_to_db(max_volume) 106 | if max_volume >= CLIPPING_THRESHOLD: 107 | print "Clipping detected (%2.2f dBFS >= %2.2f dBFS) at max volume!" % ( 108 | percent_to_db(max_volume), percent_to_db(CLIPPING_THRESHOLD) 109 | ) 110 | if EXIT_ON_CLIPPING: 111 | raise ValueError("Clipping detected at max volume!") 112 | 113 | # TODO: Finish implementing left/right balance check. 114 | # 115 | # max_volume_per_channel = [( 116 | # numpy.amax(numpy.absolute(data)) / 117 | # float(2 ** (bit_depth - 1)) 118 | # ) for channel in data] 119 | # avg_volume = ( 120 | # float(sum(max_volume_per_channel)) / 121 | # float(len(max_volume_per_channel)) 122 | # ) 123 | # print 'avg', avg_volume 124 | # print 'max', max_volume_per_channel 125 | # volume_diff_per_channel = [ 126 | # float(x) / avg_volume for x in max_volume_per_channel 127 | # ] 128 | 129 | # if any([x > VOLUME_DIFF_THRESHOLD for x in volume_diff_per_channel]): 130 | # print "Balance is skewed! Expected 50/50 volume, got %2.2f/%2.2f" % ( 131 | # volume_diff_per_channel[0] * 100, 132 | # volume_diff_per_channel[1] * 100, 133 | # ) 134 | # if EXIT_ON_BALANCE_BAD: 135 | # raise ValueError("Balance skewed!") 136 | # time.sleep(1) 137 | 138 | 139 | def fundamental_frequency(list, sampling_rate=1): 140 | w = numpy.fft.rfft(list) 141 | freqs = numpy.fft.fftfreq(len(w)) 142 | 143 | # Find the peak in the coefficients 144 | # idx = numpy.argmax(numpy.abs(w[:len(w) / 2])) 145 | idx = numpy.argmax(numpy.abs(w)) 146 | freq = freqs[idx] 147 | return abs(freq * sampling_rate) 148 | -------------------------------------------------------------------------------- /lib/compare.py: -------------------------------------------------------------------------------- 1 | import os 2 | import csv 3 | import sys 4 | import numpy 5 | import itertools 6 | import traceback 7 | from tqdm import tqdm 8 | from tabulate import tabulate 9 | 10 | from utils import normalized, trim_mono_data 11 | from audio_helpers import fundamental_frequency 12 | from wavio import read_wave_file 13 | 14 | import matplotlib.pyplot as plt 15 | 16 | sampling_rate = 44100.0 17 | assume_stereo_frequency_match = True 18 | 19 | 20 | def aligned_sublists(*lists): 21 | min_peak_index = min([numpy.argmax(list) for list in lists]) 22 | return [list[(numpy.argmax(list) - min_peak_index):] for list in lists] 23 | 24 | 25 | def peak_diff(lista, listb): 26 | return float(numpy.amax(lista)) / float(numpy.amax(listb)) 27 | 28 | 29 | def normalized_difference(lista, listb): 30 | lista = trim_mono_data(normalized(lista)) 31 | listb = trim_mono_data(normalized(listb)) 32 | 33 | compare = min(len(lista), len(listb)) 34 | return numpy.sum( 35 | numpy.absolute( 36 | lista[:compare] - listb[:compare] 37 | ) 38 | ) / compare 39 | 40 | 41 | def freq_diff(lista, listb, only_compare_first=100000): 42 | return fundamental_frequency(lista[:only_compare_first]) /\ 43 | fundamental_frequency(listb[:only_compare_first]) 44 | 45 | 46 | def shift_freq(list, factor): 47 | num_output_points = int(float(len(list)) / factor) 48 | output_x_points = numpy.linspace(0, len(list), num_output_points) 49 | input_x_points = numpy.linspace(0, len(list), len(list)) 50 | 51 | return numpy.interp( 52 | output_x_points, 53 | input_x_points, 54 | list, 55 | ) 56 | 57 | 58 | def generate_diffs(filea, fileb): 59 | wavea = read_wave_file(filea, True) 60 | waveb = read_wave_file(fileb, True) 61 | 62 | diffl = normalized_difference(*aligned_sublists(wavea[0], waveb[0])) 63 | diffr = normalized_difference(*aligned_sublists(wavea[1], waveb[1])) 64 | 65 | peakl = peak_diff(wavea[0], waveb[0]) 66 | peakr = peak_diff(wavea[1], waveb[1]) 67 | 68 | # for line in aligned_sublists(wavea[0], waveb[0]): 69 | # plt.plot(normalized(line[:10000])) 70 | # plt.show() 71 | 72 | # louder_a = wavea[0] if numpy.amax(wavea[0]) \ 73 | # > numpy.amax(wavea[1]) else wavea[1] 74 | # louder_b = waveb[0] if numpy.amax(waveb[0]) \ 75 | # > numpy.amax(waveb[1]) else waveb[1] 76 | 77 | # freqd = freq_diff(normalized(louder_a), normalized(louder_b)) 78 | 79 | return ( 80 | diffl, diffr, 81 | peakl, peakr, 82 | 0, # freqd, 83 | os.path.split(filea)[-1], os.path.split(fileb)[-1] 84 | ) 85 | 86 | 87 | def generate_pairs(infiles): 88 | for filea, fileb in tqdm(list(itertools.combinations(infiles, 2))): 89 | yield generate_diffs(filea, fileb) 90 | 91 | 92 | def process_all(aifs): 93 | results = [] 94 | try: 95 | for result in generate_pairs(aifs): 96 | results.append(result) 97 | except KeyboardInterrupt as e: 98 | traceback.print_exc(e) 99 | pass 100 | 101 | headers = ( 102 | '% diff L', '% diff R', 103 | 'x peak L', 'x peak R', 104 | 'x freq', 105 | 'file a', 'file b' 106 | ) 107 | results = sorted( 108 | results, 109 | key=lambda (dl, dr, 110 | pl, pr, 111 | freqd, 112 | fa, fb): dl + dr + abs(freqd - 1)) 113 | with open('results.csv', 'wb') as f: 114 | writer = csv.writer(f) 115 | writer.writerows([headers]) 116 | writer.writerows(results) 117 | 118 | print "%d results" % len(results) 119 | print tabulate( 120 | results, 121 | headers=headers, 122 | floatfmt='.4f' 123 | ) 124 | 125 | 126 | def graph_ffts(): 127 | files = ['A1_v111_15.00s.aif', 'A2_v31_15.00s.aif'] 128 | for file in files: 129 | stereo = read_wave_file(os.path.join(root_dir, file)) 130 | left = stereo[0] 131 | right = stereo[1] 132 | list = left[:100000] 133 | 134 | w = numpy.fft.rfft(list) 135 | freqs = numpy.fft.fftfreq(len(w)) 136 | 137 | # Find the peak in the coefficients 138 | # idx = numpy.argmax(numpy.abs(w[:len(w) / 2])) 139 | idx = numpy.argmax(numpy.abs(w)) 140 | freq = freqs[idx] 141 | plt.plot(w) 142 | print freq 143 | print \ 144 | fundamental_frequency(normalized(list)), \ 145 | fundamental_frequency(normalized(left + right)) 146 | # plt.show() 147 | 148 | 149 | def freq_shift(): 150 | files = ['A1_v111_15.00s.aif', 'A1_v95_15.00s.aif'] 151 | wavea, waveb = [ 152 | read_wave_file(os.path.join(root_dir, file)) for file in files 153 | ] 154 | 155 | louder_a = wavea[0] if (numpy.amax(wavea[0]) > 156 | numpy.amax(wavea[1])) else wavea[1] 157 | louder_b = waveb[0] if (numpy.amax(waveb[0]) > 158 | numpy.amax(waveb[1])) else waveb[1] 159 | 160 | freqd = freq_diff(normalized(louder_a), normalized(louder_b)) 161 | 162 | waveb_shifted = [shift_freq(channel, freqd) for channel in waveb] 163 | louder_shifted_b = waveb_shifted[0] if (numpy.amax(waveb_shifted[0]) > 164 | numpy.amax(waveb_shifted[1])) \ 165 | else waveb_shifted[1] 166 | 167 | shifted_freqd = freq_diff( 168 | normalized(louder_a), 169 | normalized(louder_shifted_b) 170 | ) 171 | 172 | # lefts_aligned = aligned_sublists(wavea[0], waveb[0]) 173 | rights_aligned = aligned_sublists(wavea[1], waveb[1]) 174 | # shifted_lefts_aligned = aligned_sublists(wavea[0], waveb_shifted[0]) 175 | 176 | diffl = normalized_difference(*aligned_sublists(wavea[0], waveb[0])) 177 | diffr = normalized_difference(*aligned_sublists(wavea[1], waveb[1])) 178 | 179 | plt.plot(normalized(rights_aligned[0][:10000])) 180 | plt.plot(normalized(rights_aligned[1][:10000])) 181 | plt.plot(numpy.absolute( 182 | normalized(rights_aligned[0][:10000]) - 183 | normalized(rights_aligned[1][:10000]) 184 | )) 185 | plt.show() 186 | 187 | shifted_diffl = normalized_difference(*aligned_sublists(wavea[0], 188 | waveb_shifted[0])) 189 | shifted_diffr = normalized_difference(*aligned_sublists(wavea[1], 190 | waveb_shifted[1])) 191 | 192 | print files 193 | print 'diffs\t\t', diffl, diffr 194 | print 'shifted diffs\t', shifted_diffl, shifted_diffr 195 | print 'freqs', freqd 196 | print 'shifted freqs', shifted_freqd 197 | 198 | 199 | if __name__ == "__main__": 200 | process_all(sys.argv[1:]) 201 | -------------------------------------------------------------------------------- /lib/constants.py: -------------------------------------------------------------------------------- 1 | import numpy 2 | 3 | neg80point8db = 0.00009120108393559096 4 | bit_depth = 16 5 | default_silence_threshold = (neg80point8db * (2 ** (bit_depth - 1))) * 4 6 | NUMPY_DTYPE = numpy.int16 if bit_depth == 16 else numpy.int24 7 | SAMPLE_RATE = 48000 8 | 9 | EXIT_ON_CLIPPING = True 10 | EXIT_ON_BALANCE_BAD = False # Doesn't work yet 11 | CLIPPING_CHECK_NOTE = 48 # C4 12 | CLIPPING_THRESHOLD = 0.85 13 | -------------------------------------------------------------------------------- /lib/deflac.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import wave 4 | import numpy 5 | import argparse 6 | import subprocess 7 | from tqdm import tqdm 8 | from sfzparser import SFZFile 9 | from wavio import read_wave_file 10 | from utils import normalized 11 | from record import RATE, save_to_file 12 | from constants import bit_depth 13 | 14 | 15 | def full_path(sfzfile, filename): 16 | if os.path.isdir(sfzfile): 17 | return os.path.join(sfzfile, filename) 18 | else: 19 | return os.path.join(os.path.dirname(sfzfile), filename) 20 | 21 | 22 | def length_of(filename): 23 | return wave.open(filename).getnframes() 24 | 25 | 26 | def split_flac(input_filename, start_time, end_time, output_filename): 27 | commandline = [ 28 | 'ffmpeg', 29 | '-y', 30 | '-i', 31 | input_filename, 32 | '-ss', 33 | str(start_time), 34 | '-to', 35 | str(end_time), 36 | output_filename 37 | ] 38 | # sys.stderr.write("Calling '%s'...\n" % ' '.join(commandline)) 39 | subprocess.call( 40 | commandline, 41 | stdout=open('/dev/null', 'w'), 42 | stderr=open('/dev/null', 'w') 43 | ) 44 | 45 | 46 | def normalize_file(filename): 47 | data = read_wave_file(filename, True) 48 | if len(data): 49 | normalized_data = normalized(data) * (2 ** (bit_depth - 1) - 1) 50 | else: 51 | normalized_data = data 52 | save_to_file(filename, 2, normalized_data) 53 | 54 | ANTI_CLICK_OFFSET = 3 55 | 56 | 57 | def split_sample(region, path): 58 | new_file_name = "%s_%s_%s.wav" % ( 59 | region.attributes['key'], 60 | region.attributes['lovel'], 61 | region.attributes['hivel'] 62 | ) 63 | output_file_path = full_path(path, new_file_name) 64 | if not os.path.isfile(output_file_path): 65 | split_flac( 66 | full_path(path, region.attributes['sample']), 67 | float(region.attributes['offset']) / float(RATE), 68 | float(region.attributes['end']) / float(RATE), 69 | output_file_path 70 | ) 71 | normalize_file(output_file_path) 72 | 73 | 74 | if __name__ == "__main__": 75 | parser = argparse.ArgumentParser( 76 | description='split up flac-ized SFZ file into wavs' 77 | ) 78 | parser.add_argument( 79 | 'files', 80 | type=str, 81 | help='sfz files to process', 82 | nargs='+' 83 | ) 84 | args = parser.parse_args() 85 | 86 | all_regions = [ 87 | regions 88 | for filename in args.files 89 | for group in SFZFile(open(filename).read()).groups 90 | for regions in group.regions 91 | ] 92 | for regions in tqdm(all_regions, desc='De-flacing...'): 93 | split_sample(regions, filename) 94 | -------------------------------------------------------------------------------- /lib/flacize.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import wave 4 | import time 5 | import argparse 6 | import subprocess 7 | from tqdm import tqdm 8 | from sfzparser import SFZFile, Group 9 | from wavio import read_wave_file 10 | from utils import group_by_attr, note_name 11 | 12 | 13 | def full_path(sfzfile, filename): 14 | if os.path.isdir(sfzfile): 15 | return os.path.join(sfzfile, filename) 16 | else: 17 | return os.path.join(os.path.dirname(sfzfile), filename) 18 | 19 | 20 | def length_of(filename): 21 | return wave.open(filename).getnframes() 22 | 23 | 24 | def create_flac(concat_filename, output_filename): 25 | commandline = [ 26 | 'ffmpeg', 27 | '-y', 28 | '-f', 29 | 'concat', 30 | '-safe', 31 | '0', 32 | '-i', 33 | concat_filename, 34 | '-c:a', 35 | 'flac', 36 | '-compression_level', '12', 37 | output_filename 38 | ] 39 | # sys.stderr.write("Calling '%s'...\n" % ' '.join(commandline)) 40 | subprocess.check_call( 41 | commandline, 42 | stdout=open(os.devnull, 'w'), 43 | stderr=subprocess.STDOUT 44 | ) 45 | 46 | 47 | def flacize_after_sampling( 48 | output_folder, 49 | groups, 50 | sfzfile, 51 | cleanup_aif_files=True 52 | ): 53 | new_groups = [] 54 | 55 | old_paths_to_unlink = [ 56 | full_path(output_folder, r.attributes['sample']) 57 | for group in groups 58 | for r in group.regions 59 | ] 60 | 61 | for group in groups: 62 | # Make one FLAC file per key, to get more compression. 63 | output = sum([list(concat_samples( 64 | key_regions, output_folder, note_name(key) 65 | )) 66 | for key, key_regions in 67 | group_by_attr(group.regions, [ 68 | 'key', 'pitch_keycenter' 69 | ]).iteritems()], []) 70 | new_groups.append(Group(group.attributes, output)) 71 | 72 | with open(sfzfile + '.flac.sfz', 'w') as file: 73 | file.write("\n".join([str(group) for group in new_groups])) 74 | 75 | if cleanup_aif_files: 76 | for path in old_paths_to_unlink: 77 | try: 78 | os.unlink(path) 79 | except OSError as e: 80 | print "Could not unlink path: %s: %s" % (path, e) 81 | 82 | 83 | ANTI_CLICK_OFFSET = 3 84 | 85 | 86 | def concat_samples(regions, path, name=None): 87 | if name is None: 88 | output_filename = 'all_samples_%f.flac' % time.time() 89 | else: 90 | output_filename = '%s.flac' % name 91 | 92 | concat_filename = 'concat.txt' 93 | 94 | with open(concat_filename, 'w') as outfile: 95 | global_offset = 0 96 | for region in regions: 97 | sample = region.attributes['sample'] 98 | 99 | sample_data = read_wave_file(full_path(path, sample)) 100 | 101 | sample_length = len(sample_data[0]) 102 | region.attributes['offset'] = global_offset 103 | region.attributes['end'] = ( 104 | global_offset + sample_length - ANTI_CLICK_OFFSET 105 | ) 106 | # TODO: make sure endpoint is a zero crossing to prevent clicks 107 | region.attributes['sample'] = output_filename 108 | outfile.write("file '%s'\n" % full_path(path, sample)) 109 | global_offset += sample_length 110 | 111 | create_flac(concat_filename, full_path(path, output_filename)) 112 | os.unlink(concat_filename) 113 | 114 | return regions 115 | 116 | 117 | if __name__ == "__main__": 118 | parser = argparse.ArgumentParser( 119 | description='flac-ize SFZ files into one sprite sample' 120 | ) 121 | parser.add_argument('files', type=str, help='files to process', nargs='+') 122 | args = parser.parse_args() 123 | 124 | for filename in args.files: 125 | for group in SFZFile(open(filename).read()).groups: 126 | # Make one FLAC file per key, to get more compression. 127 | output = sum([list(concat_samples(regions, 128 | filename, 129 | note_name(key))) 130 | for key, regions in 131 | tqdm(group_by_attr(group.regions, 132 | 'key').iteritems())], []) 133 | print group.just_group() 134 | for region in output: 135 | print region 136 | -------------------------------------------------------------------------------- /lib/graph.py: -------------------------------------------------------------------------------- 1 | import os 2 | import csv 3 | import sys 4 | import numpy 5 | import itertools 6 | import traceback 7 | from tqdm import tqdm 8 | from tabulate import tabulate 9 | 10 | from wavio import read_wave_file 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | sampling_rate = 48000.0 15 | assume_stereo_frequency_match = True 16 | CHUNK_SIZE = 2048 17 | 18 | 19 | def process_all(start, stop, *files): 20 | start = int(start) 21 | stop = int(stop) 22 | chunk_offset = ((-1 * start) % CHUNK_SIZE) 23 | for file in files: 24 | stereo = read_wave_file(file) 25 | left = stereo[0] 26 | right = stereo[1] 27 | plt.plot(left[start:stop]) 28 | plt.plot(right[start:stop]) 29 | plt.show() 30 | 31 | if __name__ == "__main__": 32 | process_all(*sys.argv[1:]) 33 | -------------------------------------------------------------------------------- /lib/group_velcurves.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from sfzparser import parse, Group 3 | from quantize import group_by_attr 4 | 5 | parser = argparse.ArgumentParser( 6 | description='quantize and compress SFZ files' 7 | ) 8 | parser.add_argument('files', type=str, help='files to process', nargs='+') 9 | args = parser.parse_args() 10 | 11 | 12 | def should_group_key(key): 13 | return ( 14 | key.startswith('amp_velcurve_') or 15 | key == 'key' or 16 | key == 'ampeg_release' 17 | ) 18 | 19 | 20 | def group_by_pitch(regions): 21 | for key, regions in group_by_attr(regions, 'key').iteritems(): 22 | # Group together all amp_velcurve_* and key params. 23 | yield Group(dict([ 24 | (key, value) 25 | for region in regions 26 | for key, value in region.attributes.iteritems() 27 | if should_group_key(key) 28 | ] + DEFAULT_ATTRIBUTES.items()), [ 29 | region.without_attributes(should_group_key) for region in regions 30 | ]) 31 | 32 | 33 | if __name__ == "__main__": 34 | parser = argparse.ArgumentParser( 35 | description='flac-ize SFZ files into one sprite sample' 36 | ) 37 | parser.add_argument('files', type=str, help='files to process', nargs='+') 38 | args = parser.parse_args() 39 | 40 | for filename in args.files: 41 | groups = parse(open(filename).read()) 42 | regions = sum([group.flattened_regions() for group in groups], []) 43 | for group in group_by_pitch(regions): 44 | print group 45 | -------------------------------------------------------------------------------- /lib/loop.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy 3 | from tqdm import tqdm 4 | from truncate import read_wave_file 5 | from audio_helpers import fundamental_frequency 6 | 7 | QUANTIZE_FACTOR = 8 8 | 9 | 10 | def compare_windows(window_a, window_b): 11 | return numpy.sqrt(numpy.mean(numpy.power(window_a - window_b, 2))) 12 | 13 | 14 | def slide_window(file, period, start_at=0, end_before=0): 15 | for power in reversed(xrange(7, 10)): 16 | multiple = 2 ** power 17 | window_size = int(period * multiple) 18 | # Uncomment this to search from the start_at value to the end_before 19 | # rather than just through one window's length 20 | # end_range = len(file) - (window_size * 2) - end_before 21 | end_range = start_at + window_size 22 | for i in xrange(start_at, end_range): 23 | yield power, i, window_size 24 | 25 | 26 | def window_match(file): 27 | period = (1.0 / fundamental_frequency(file, 1)) * 2 28 | print period, 'period in samples' 29 | 30 | winner = None 31 | 32 | window_positions = list( 33 | slide_window(file, period, len(file) / 2, len(file) / 8) 34 | ) 35 | for power, i, window_size in tqdm(window_positions): 36 | window_start = find_similar_sample_index(file, i, i + window_size) 37 | window_end = find_similar_sample_index(file, i, i + (window_size * 2)) 38 | effective_size = window_end - window_start 39 | 40 | difference = compare_windows( 41 | file[i:i + effective_size], 42 | file[window_start:window_end] 43 | ) / effective_size 44 | if winner is None or difference < winner[0]: 45 | winner = ( 46 | difference, 47 | effective_size, 48 | i, 49 | abs(file[i] - file[window_start]) 50 | ) 51 | print 'new winner', winner 52 | 53 | lowest_difference, winning_window_size, winning_index, gap = winner 54 | 55 | print "Best loop match:", lowest_difference 56 | print "window size", winning_window_size 57 | print "winning index", winning_index 58 | print "winning gap", gap 59 | return winning_index, winning_window_size 60 | 61 | 62 | def slope_at_index(file, i): 63 | return (file[i + 1] - file[i - 1]) / 2 64 | 65 | 66 | def find_similar_sample_index( 67 | file, 68 | reference_index, 69 | search_around_index, 70 | search_size=100 # samples 71 | ): 72 | reference_slope = slope_at_index(file, reference_index) > 0 73 | best_match = None 74 | search_range = xrange( 75 | search_around_index - search_size, 76 | search_around_index + search_size 77 | ) 78 | for i in search_range: 79 | slope = slope_at_index(file, i) > 0 80 | if slope != reference_slope: 81 | continue 82 | 83 | abs_diff = abs(file[i] - file[reference_index]) 84 | 85 | if best_match is not None: 86 | _, best_abs_diff = best_match 87 | if abs_diff < best_abs_diff: 88 | best_match = (i, abs_diff) 89 | else: 90 | best_match = (i, abs_diff) 91 | return best_match[0] if best_match is not None else search_around_index 92 | 93 | 94 | def zero_crossing_match(file): 95 | period = (1.0 / fundamental_frequency(file, 1)) * 2 96 | print period, 'period in samples' 97 | 98 | period_multiple = 64 99 | period = period * period_multiple 100 | 101 | for i in reversed(xrange(2 * len(file) / 3, 5 * len(file) / 6)): 102 | if file[i] >= 0 and file[i + 1] < 0 and \ 103 | file[int(i + period)] >= 0 and \ 104 | file[int(i + 1 + period)] < 0 and \ 105 | file[int(i + period * 2)] >= 0 and \ 106 | file[int(i + 1 + period * 2)] < 0: 107 | return i, int(period) 108 | 109 | 110 | def fast_autocorrelate(x): 111 | """ 112 | Compute the autocorrelation of the signal, based on the properties of the 113 | power spectral density of the signal. 114 | 115 | Note that the input's length may be reduced before the correlation is 116 | performed due to a pathalogical case in numpy.fft: 117 | http://stackoverflow.com/a/23531074/679081 118 | 119 | > The FFT algorithm used in np.fft performs very well (meaning O(n log n)) 120 | > when the input length has many small prime factors, and very bad 121 | > (meaning a naive DFT requiring O(n^2)) when the input size is a prime 122 | > number. 123 | """ 124 | 125 | # This is one simple way to ensure that the input array 126 | # has a length with many small prime factors, although it 127 | # doesn't guarantee that (also hopefully we don't chop too much) 128 | optimal_input_length = int(numpy.sqrt(len(x))) ** 2 129 | x = x[:optimal_input_length] 130 | xp = x - numpy.mean(x) 131 | f = numpy.fft.fft(xp) 132 | p = numpy.absolute(numpy.power(f, 2)) 133 | pi = numpy.fft.ifft(p) 134 | result = numpy.real(pi)[:x.size / 2] / numpy.sum(numpy.power(xp, 2)) 135 | return result 136 | 137 | 138 | def find_argmax_after(file, offset): 139 | return numpy.argmax(file[offset:]) + offset 140 | 141 | 142 | def autocorrelated_loop(file, search_start, min_loop_width_in_seconds=0.2): 143 | # Strategy: 144 | # 1) run an autocorrelation on the file. 145 | # 3) Find argmax of the autocorrelation 146 | # 4) define some peak_width and find the next highest peak after current 147 | # 5) define the loop bounds as from the first peak to the second peak 148 | # 6) massage the loop bounds using find_similar_sample_index 149 | # 7) ??? 150 | # 8) Profit! 151 | autocorrelation = fast_autocorrelate(file) 152 | return find_loop_from_autocorrelation( 153 | file, 154 | autocorrelation, 155 | search_start, 156 | min_loop_width_in_seconds 157 | ) 158 | 159 | 160 | def find_loop_from_autocorrelation( 161 | file, 162 | autocorrelation, 163 | search_start, 164 | min_loop_width_in_seconds=0.2, 165 | sample_rate=48000 166 | ): 167 | search_start /= 2 168 | max_autocorrelation_peak_width = int( 169 | min_loop_width_in_seconds * sample_rate 170 | ) 171 | loop_start = find_argmax_after(autocorrelation, search_start) 172 | loop_end = find_argmax_after( 173 | autocorrelation, 174 | loop_start + max_autocorrelation_peak_width 175 | ) 176 | 177 | loop_end = find_similar_sample_index(file, loop_start, loop_end) - 1 178 | return loop_start, (loop_end - loop_start) 179 | 180 | 181 | def minimize(iterable, callable): 182 | best_result = None 183 | best_score = None 184 | for x in iterable: 185 | if x: 186 | score = callable(*x) 187 | if best_score is None or score < best_score: 188 | best_score = score 189 | best_result = x 190 | return best_result 191 | 192 | 193 | def autocorrelate_loops(file, sample_rate): 194 | autocorrelation = fast_autocorrelate(file) 195 | search_points = [ 196 | 3 * len(file) / 4, 197 | 2 * len(file) / 3, 198 | len(file) / 2, 199 | len(file) / 3, 200 | ] 201 | loop_widths = [0.2, 0.4, 0.6, 0.8, 1.0, 1.5, 2, 2.5, 3.] 202 | for search_point in search_points: 203 | for width in loop_widths: 204 | try: 205 | yield find_loop_from_autocorrelation( 206 | file, autocorrelation, 207 | search_point, width, sample_rate) 208 | except ValueError: 209 | # We couldn't search for a loop width of that size. 210 | pass 211 | yield None 212 | 213 | 214 | def find_loop_points(data, sample_rate): 215 | channel = data[0] 216 | 217 | result = minimize( 218 | autocorrelate_loops(channel, sample_rate), 219 | lambda start, length: abs(channel[start] - channel[start + length]) 220 | ) 221 | 222 | if result: 223 | loop_start, loop_size = result 224 | return loop_start, loop_start + loop_size 225 | 226 | 227 | def process(aif, sample_rate=48000): 228 | file = read_wave_file(aif) 229 | 230 | # loop_start, loop_size = window_match(file) 231 | # loop_start, loop_size = zero_crossing_match(file) 232 | loop_start, loop_end = find_loop_points(file) 233 | loop_size = loop_end - loop_start 234 | 235 | file = file[0] 236 | 237 | print 'start, end', loop_start, loop_end 238 | 239 | plt.plot(file[loop_start:loop_end]) 240 | plt.plot(file[loop_end:loop_start + (2 * loop_size)]) 241 | plt.show() 242 | 243 | plt.plot(file[ 244 | loop_start - (sample_rate * 2): 245 | loop_start + (sample_rate * 2) 246 | ]) 247 | plt.axvline(sample_rate * 2) 248 | plt.axvline((sample_rate * 2) + loop_size) 249 | plt.show() 250 | 251 | if __name__ == "__main__": 252 | import matplotlib.pyplot as plt 253 | process(sys.argv[1]) 254 | -------------------------------------------------------------------------------- /lib/map_xfvel.py: -------------------------------------------------------------------------------- 1 | def map_xfvel(regions): 2 | for region in regions: 3 | region.attributes.update({ 4 | 'hivel': region.attributes['xfin_hivel'], 5 | 'lovel': region.attributes['xfin_lovel'], 6 | }) 7 | del region.attributes['xfin_hivel'] 8 | del region.attributes['xfin_lovel'] 9 | del region.attributes['xfout_hivel'] 10 | del region.attributes['xfout_lovel'] 11 | yield region 12 | -------------------------------------------------------------------------------- /lib/midi_helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | import rtmidi 3 | 4 | 5 | CHANNEL_OFFSET = 0x90 - 1 6 | CC_CHANNEL_OFFSET = 0xB0 - 1 7 | 8 | 9 | class Midi(object): 10 | 11 | def __init__(self, midi_out, channel=1): 12 | self.midi = midi_out # TODO Initialize rtmidi here, not outside 13 | self.channel = channel 14 | 15 | def cc(self, cc, val, channel=None): 16 | """Send a Continuous Controller change.""" 17 | cc = int(cc) 18 | val = int(val) 19 | assert -1 < cc < 128 20 | assert -1 < val < 128 21 | print('Sending MIDI CC #{} with value {}.'.format(cc, val)) 22 | self.midi.send_message([ 23 | CC_CHANNEL_OFFSET + (channel or self.channel), cc, val]) 24 | 25 | 26 | def all_notes_off(midiout, midi_channel): 27 | # All notes off 28 | midiout.send_message([ 29 | CC_CHANNEL_OFFSET + midi_channel, 0x7B, 0 30 | ]) 31 | # Reset all controllers 32 | midiout.send_message([ 33 | CC_CHANNEL_OFFSET + midi_channel, 0x79, 0 34 | ]) 35 | 36 | 37 | def open_midi_port(midi_port_name): 38 | midiout = rtmidi.MidiOut() 39 | ports = midiout.get_ports() 40 | for i, port_name in enumerate(ports): 41 | if not midi_port_name or midi_port_name.lower() in port_name.lower(): 42 | midiout.open_port(i) 43 | return midiout 44 | else: 45 | raise Exception("Could not find port matching '%s' in ports:\n%s" % ( 46 | midi_port_name, list_midi_ports(ports) 47 | )) 48 | 49 | 50 | def set_program_number(midiout, midi_channel, program_number): 51 | if program_number is not None: 52 | print "Sending program change to program %d..." % program_number 53 | # Bank change (fine) to (program_number / 128) 54 | midiout.send_message([ 55 | CC_CHANNEL_OFFSET + midi_channel, 56 | 0x20, 57 | int(program_number / 128), 58 | ]) 59 | # Program change to program number % 128 60 | midiout.send_message([ 61 | CHANNEL_OFFSET + midi_channel, 62 | 0xC0, 63 | program_number % 128, 64 | ]) 65 | 66 | # All notes off, but like, a lot 67 | for _ in xrange(0, 2): 68 | all_notes_off(midiout, midi_channel) 69 | 70 | time.sleep(0.5) 71 | 72 | 73 | def open_midi_port_by_index(midi_port_index): 74 | midiout = rtmidi.MidiOut() 75 | ports = midiout.get_ports() 76 | if midi_port_index > 0 and midi_port_index <= len(ports): 77 | midiout.open_port(midi_port_index - 1) 78 | return midiout 79 | else: 80 | raise Exception( 81 | "MIDI port index '%d' out of range.\n%s" 82 | % (midi_port_index, list_midi_ports(ports),) 83 | ) 84 | 85 | 86 | def list_midi_ports(ports): 87 | lines = [] 88 | for i, port_name in enumerate(ports): 89 | lines.append("{:3d}. {}".format(i + 1, port_name)) 90 | return "\n".join(lines) 91 | -------------------------------------------------------------------------------- /lib/numpy_helpers.py: -------------------------------------------------------------------------------- 1 | import numpy 2 | # from https://gist.github.com/nils-werner/9d321441006b112a4b116a8387c2280c 3 | 4 | 5 | def sliding_window(data, size, stepsize=1, padded=False, axis=-1, copy=True): 6 | """ 7 | Calculate a sliding window over a signal 8 | Parameters 9 | ---------- 10 | data : numpy array 11 | The array to be slided over. 12 | size : int 13 | The sliding window size 14 | stepsize : int 15 | The sliding window stepsize. Defaults to 1. 16 | axis : int 17 | The axis to slide over. Defaults to the last axis. 18 | copy : bool 19 | Return strided array as copy to avoid sideffects when manipulating the 20 | output array. 21 | Returns 22 | ------- 23 | data : numpy array 24 | A matrix where row in last dimension consists of one instance 25 | of the sliding window. 26 | Notes 27 | ----- 28 | - Be wary of setting `copy` to `False` as undesired sideffects with the 29 | output values may occurr. 30 | Examples 31 | -------- 32 | >>> a = numpy.array([1, 2, 3, 4, 5]) 33 | >>> sliding_window(a, size=3) 34 | array([[1, 2, 3], 35 | [2, 3, 4], 36 | [3, 4, 5]]) 37 | >>> sliding_window(a, size=3, stepsize=2) 38 | array([[1, 2, 3], 39 | [3, 4, 5]]) 40 | See Also 41 | -------- 42 | pieces : Calculate number of pieces available by sliding 43 | """ 44 | if axis >= data.ndim: 45 | raise ValueError( 46 | "Axis value out of range" 47 | ) 48 | 49 | if stepsize < 1: 50 | raise ValueError( 51 | "Stepsize may not be zero or negative" 52 | ) 53 | 54 | if size > data.shape[axis]: 55 | raise ValueError( 56 | "Sliding window size may not exceed size of selected axis" 57 | ) 58 | 59 | shape = list(data.shape) 60 | shape[axis] = numpy.floor( 61 | data.shape[axis] / stepsize - size / stepsize + 1 62 | ).astype(int) 63 | shape.append(size) 64 | 65 | strides = list(data.strides) 66 | strides[axis] *= stepsize 67 | strides.append(data.strides[axis]) 68 | 69 | strided = numpy.lib.stride_tricks.as_strided( 70 | data, shape=shape, strides=strides 71 | ) 72 | 73 | if copy: 74 | return strided.copy() 75 | else: 76 | return strided 77 | -------------------------------------------------------------------------------- /lib/pitch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Computations concerning pitch. 5 | 6 | Let me define 2 terms: 7 | 8 | - A "zone" is simply a pitch range -- an area of the keyboard, 9 | from a low key to a high key. 10 | - A "region" is a concept of sfz that consubstantiates a *zone*, but also 11 | contains information about velocities (and probably more). 12 | 13 | This module deals with pitch only -- zones, not regions. 14 | """ 15 | 16 | from __future__ import (absolute_import, division, print_function, 17 | unicode_literals) 18 | 19 | # TODO Move here translation of 60 to "C4" etc from "utils" module. 20 | 21 | 22 | class Zone(object): 23 | """Smart data structure to store 2 pitches.""" 24 | 25 | def __init__(self, low, high, center=None): 26 | """Expect parameters in the range 0-127. 27 | 28 | Middle C is 60 and a grand piano goes from 21 to 108. 29 | 30 | Only in tests is the parameter "center" used in the constructor. 31 | In actual code it is populated after construction. 32 | """ 33 | assert isinstance(low, int) 34 | assert isinstance(high, int) 35 | assert high >= low 36 | assert high < 128 37 | assert low > -1 38 | self.low = low 39 | self.high = high 40 | self.center = center 41 | 42 | @property 43 | def size(self): 44 | """Return the width of this range.""" 45 | return self.high - self.low 46 | 47 | @property 48 | def keys(self): 49 | return list(range(self.low, self.high)) 50 | 51 | def __repr__(self): 52 | return ''.format( 53 | self.low, self.high, self.center) 54 | 55 | def __eq__(self, other): 56 | """Compare self to ``other``. Makes test writing easier.""" 57 | return ( 58 | self.low == other.low 59 | and self.high == other.high 60 | and self.center == other.center) 61 | 62 | 63 | def optimal_pitch_center(step=1): 64 | """Given a step size, decide where to place the pitch center. 65 | 66 | Preference is given to extending the region downwards -- sounds better. 67 | """ 68 | assert 0 < step < 128 69 | 70 | def _a_generator(): 71 | yield False 72 | yield True 73 | while True: 74 | yield False 75 | yield False 76 | yield True 77 | 78 | answer = 0 79 | a_generator = _a_generator() 80 | for _ in range(step - 1): 81 | if not next(a_generator): 82 | answer += 1 83 | return answer 84 | 85 | 86 | def compute_zones(pitch_range, step): 87 | """Plan the keyboard zones (with their pitch centers) to cover a range. 88 | 89 | The param ``pitch_range`` must be a Zone instance defining the 90 | part of the keyboard that you want sampled. 91 | 92 | The param ``step`` is typically 1 (meaning sample each note) or 93 | 3 (meaning sample in minor thirds). 94 | """ 95 | assert isinstance(step, int) 96 | assert isinstance(pitch_range, Zone) 97 | pitch_center_offset = optimal_pitch_center(step) 98 | regions = [] 99 | low = pitch_range.low 100 | while True: 101 | zone = Zone(low=low, high=min(low + step - 1, pitch_range.high)) 102 | zone.center = low + pitch_center_offset 103 | regions.append(zone) 104 | low += step 105 | if low > pitch_range.high: 106 | break 107 | return regions 108 | -------------------------------------------------------------------------------- /lib/quantize.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import argparse 4 | from sfzparser import SFZFile 5 | from utils import group_by_attr 6 | import itertools 7 | from collections import defaultdict 8 | 9 | parser = argparse.ArgumentParser( 10 | description='quantize and compress SFZ files' 11 | ) 12 | parser.add_argument('files', type=str, help='files to process', nargs='+') 13 | args = parser.parse_args() 14 | 15 | 16 | def quantize_pitch(regions, pitch_levels=25): 17 | lowestkey = min(map(lambda x: int(x.attributes['key']), regions)) 18 | highestkey = max(map(lambda x: int(x.attributes['key']), regions)) 19 | 20 | keyspan = highestkey - lowestkey 21 | pitch_skip = keyspan / pitch_levels 22 | 23 | evenly_divided = \ 24 | int(keyspan / pitch_levels) == float(keyspan) / float(pitch_levels) 25 | 26 | # a dict of sample_pitch -> [lokey, hikey, pitch_keycenter] 27 | pitchmapping = {} 28 | for key in xrange( 29 | lowestkey + (pitch_skip / 2), 30 | highestkey + 1 + (pitch_skip / 2), 31 | pitch_skip): 32 | pitchmapping[key] = { 33 | 'lokey': key - (pitch_skip / 2), 34 | 'pitch_keycenter': key, 35 | 'hikey': key + (pitch_skip / 2) - (0 if evenly_divided else 1), 36 | } 37 | 38 | for key, regions in group_by_attr(regions, 'key').iteritems(): 39 | if int(key) in pitchmapping: 40 | for region in regions: 41 | region.attributes.update(pitchmapping[int(key)]) 42 | del region.attributes['key'] 43 | yield region 44 | 45 | 46 | def quantize_velocity(regions, velocity_levels=5): 47 | lowestvel = min(map(lambda x: int(x.attributes['xfin_loivel']), regions)) 48 | highestvel = max(map(lambda x: int(x.attributes['xfin_hivel']), regions)) 49 | 50 | velspan = 127 51 | pitch_skip = velspan / velocity_levels 52 | 53 | evenly_divided = \ 54 | int(keyspan / pitch_levels) == float(keyspan) / float(pitch_levels) 55 | 56 | # a dict of sample_pitch -> [lokey, hikey, pitch_keycenter] 57 | pitchmapping = {} 58 | for key in xrange( 59 | lowestkey + (pitch_skip / 2), 60 | highestkey + 1 + (pitch_skip / 2), 61 | pitch_skip): 62 | pitchmapping[key] = { 63 | 'lokey': key - (pitch_skip / 2), 64 | 'pitch_keycenter': key, 65 | 'hikey': key + (pitch_skip / 2) - (0 if evenly_divided else 1), 66 | } 67 | 68 | for key, regions in group_by_attr(regions, 'key').iteritems(): 69 | if int(key) in pitchmapping: 70 | for region in regions: 71 | region.attributes.update(pitchmapping[int(key)]) 72 | del region.attributes['key'] 73 | yield region 74 | 75 | 76 | def compute_sample_size(filename, regions): 77 | size = 0 78 | 79 | for region in regions: 80 | fullpath = os.path.join( 81 | os.path.dirname(filename), 82 | region.attributes['sample'] 83 | ) 84 | size += os.stat(fullpath).st_size 85 | return size 86 | 87 | 88 | if __name__ == "__main__": 89 | parser = argparse.ArgumentParser( 90 | description='flac-ize SFZ files into one sprite sample' 91 | ) 92 | parser.add_argument('files', type=str, help='files to process', nargs='+') 93 | args = parser.parse_args() 94 | 95 | for filename in args.files: 96 | groups = SFZFile(open(filename).read()).groups 97 | sys.stderr.write( 98 | "Original sample size: %d bytes\n" % 99 | compute_sample_size( 100 | filename, 101 | sum([group.regions for group in groups], []) 102 | ) 103 | ) 104 | regions = sum([group.regions for group in groups], []) 105 | output = list(quantize_pitch(regions)) 106 | sys.stderr.write( 107 | "Quantized sample size: %d bytes\n" % 108 | compute_sample_size( 109 | filename, 110 | output 111 | ) 112 | ) 113 | for region in output: 114 | print region 115 | # for group in groups: 116 | # print group 117 | -------------------------------------------------------------------------------- /lib/record.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy 3 | from struct import pack 4 | from constants import bit_depth, NUMPY_DTYPE, SAMPLE_RATE 5 | from utils import percent_to_db, dbfs_as_percent 6 | 7 | import pyaudio 8 | import wave 9 | 10 | CHUNK_SIZE = 1024 11 | NUM_CHANNELS = 2 12 | FORMAT = pyaudio.paInt16 if bit_depth == 16 else pyaudio.paInt24 13 | 14 | if not sys.platform == "win32": 15 | GO_UP = "\033[F" 16 | ERASE = "\033[2K" 17 | else: 18 | GO_UP = "\n" 19 | ERASE = "" 20 | 21 | 22 | def is_silent(snd_data, threshold): 23 | maxval = max( 24 | abs(numpy.amax(snd_data)), 25 | abs(numpy.amin(snd_data)) 26 | ) / float(2 ** (bit_depth - 1)) 27 | return maxval < threshold 28 | 29 | 30 | def get_input_device_names(py_audio, info): 31 | input_interface_names = {} 32 | for i in range(0, info.get('deviceCount')): 33 | device_info = py_audio.get_device_info_by_host_api_device_index(0, i) 34 | if device_info.get('maxInputChannels') > 0: 35 | input_interface_names[i] = device_info.get('name') 36 | return input_interface_names 37 | 38 | 39 | def get_input_device_index(py_audio, audio_interface_name=None): 40 | info = py_audio.get_host_api_info_by_index(0) 41 | input_interface_names = get_input_device_names(py_audio, info) 42 | 43 | if audio_interface_name: 44 | for index, name in input_interface_names.iteritems(): 45 | if audio_interface_name.lower() in name.lower(): 46 | return index 47 | else: 48 | raise Exception( 49 | "Could not find audio input '%s' in inputs:\n%s" % ( 50 | audio_interface_name, 51 | list_input_devices(input_interface_names))) 52 | 53 | 54 | def get_input_device_name_by_index(audio_interface_index): 55 | py_audio = pyaudio.PyAudio() 56 | info = py_audio.get_host_api_info_by_index(0) 57 | input_interface_names = get_input_device_names(py_audio, info) 58 | 59 | for index, name in input_interface_names.iteritems(): 60 | if index == audio_interface_index: 61 | return name 62 | else: 63 | raise Exception( 64 | "Could not find audio input index %s in inputs:\n%s" % ( 65 | audio_interface_index, 66 | list_input_devices(input_interface_names))) 67 | 68 | 69 | def list_input_devices(device_names): 70 | lines = [] 71 | for index, name in sorted(device_names.iteritems()): 72 | lines.append(u"{:3d}. {}".format(index, name)) 73 | return u"\n".join(lines).encode("ascii", "ignore") 74 | 75 | 76 | def record( 77 | limit=None, 78 | after_start=None, 79 | on_time_up=None, 80 | threshold=0.00025, 81 | print_progress=True, 82 | allow_empty_return=False, 83 | audio_interface_name=None, 84 | sample_rate=SAMPLE_RATE, 85 | ): 86 | p = pyaudio.PyAudio() 87 | input_device_index = get_input_device_index(p, audio_interface_name) 88 | 89 | stream = p.open( 90 | format=FORMAT, 91 | channels=NUM_CHANNELS, 92 | rate=sample_rate, 93 | input=True, 94 | output=False, 95 | frames_per_buffer=CHUNK_SIZE, 96 | input_device_index=input_device_index 97 | ) 98 | 99 | num_silent = 0 100 | silence_timeout = sample_rate * 2.0 101 | snd_started = False 102 | in_tail = False 103 | release_time = None 104 | 105 | if print_progress: 106 | sys.stderr.write("\n") 107 | 108 | peak_value = None 109 | peak_index = None 110 | 111 | data = [] 112 | total_length = 0 113 | 114 | while 1: 115 | if total_length > 0 and after_start is not None: 116 | after_start() 117 | after_start = None # don't call back again 118 | array = stream.read(CHUNK_SIZE) 119 | snd_data = numpy.fromstring(array, dtype=NUMPY_DTYPE) 120 | snd_data = numpy.reshape(snd_data, (2, -1), 'F') 121 | 122 | peak_in_buffer = numpy.amax(numpy.absolute(snd_data), 1) 123 | peak_in_buffer_idx = numpy.argmax(numpy.absolute(snd_data)) 124 | mono_peak_in_buffer = max(peak_in_buffer) 125 | 126 | if peak_value is None or peak_value < mono_peak_in_buffer: 127 | peak_value = mono_peak_in_buffer 128 | peak_index = total_length + peak_in_buffer_idx 129 | 130 | data.append(snd_data) 131 | total_length += len(snd_data[0]) 132 | total_duration_seconds = float(total_length) / sample_rate 133 | 134 | time_since_peak = total_length - peak_index 135 | peak_pct = mono_peak_in_buffer / peak_value 136 | if time_since_peak: 137 | estimated_remaining_duration = peak_pct / time_since_peak 138 | else: 139 | estimated_remaining_duration = 1 140 | 141 | if print_progress: 142 | raw_percentages = ( 143 | peak_in_buffer.astype(numpy.float) / 144 | float(2 ** (bit_depth - 1)) 145 | ) 146 | dbfs = [percent_to_db(x) for x in raw_percentages] 147 | pct_loudness = [dbfs_as_percent(db) for db in dbfs] 148 | sys.stderr.write(ERASE) 149 | sys.stderr.write("\t%2.2f secs\t" % total_duration_seconds) 150 | sys.stderr.write("% 7.2f dBFS\t\t|%s%s|\n" % ( 151 | dbfs[0], 152 | int(40 * pct_loudness[0]) * '=', 153 | int(40 * (1 - pct_loudness[0])) * ' ', 154 | )) 155 | sys.stderr.write(ERASE) 156 | sys.stderr.write("\t\t\t% 7.2f dBFS\t\t|%s%s|\n" % ( 157 | dbfs[1], 158 | int(40 * pct_loudness[1]) * '=', 159 | int(40 * (1 - pct_loudness[1])) * ' ', 160 | )) 161 | pct_silence_end = float(num_silent) / silence_timeout 162 | estimated_remaining_duration_string = \ 163 | "est. remaining duration: %2.2f secs" % ( 164 | estimated_remaining_duration 165 | ) 166 | if in_tail: 167 | sys.stderr.write(ERASE) 168 | sys.stderr.write("\t\treleasing\t\tsilence:|%s%s| %s" % ( 169 | int(40 * pct_silence_end) * '=', 170 | int(40 * (1 - pct_silence_end)) * ' ', 171 | estimated_remaining_duration_string, 172 | )) 173 | else: 174 | sys.stderr.write(ERASE) 175 | sys.stderr.write("\t\t\t\t\tsilence:|%s%s| %s" % ( 176 | int(40 * pct_silence_end) * '=', 177 | int(40 * (1 - pct_silence_end)) * ' ', 178 | estimated_remaining_duration_string, 179 | )) 180 | sys.stderr.write(GO_UP) 181 | sys.stderr.write(GO_UP) 182 | 183 | silent = is_silent(snd_data, threshold) 184 | 185 | if silent: 186 | num_silent += CHUNK_SIZE 187 | elif not snd_started: 188 | snd_started = True 189 | else: 190 | num_silent = 0 191 | 192 | if num_silent > silence_timeout: 193 | if on_time_up is not None: 194 | on_time_up() 195 | break 196 | elif not in_tail \ 197 | and limit is not None \ 198 | and total_duration_seconds >= limit: 199 | if on_time_up is not None: 200 | if on_time_up(): 201 | num_silent = 0 202 | in_tail = True 203 | release_time = total_duration_seconds 204 | else: 205 | break 206 | else: 207 | break 208 | 209 | if print_progress: 210 | sys.stderr.write("\n\n\n") 211 | 212 | # TODO this is inefficient, should preallocate a huge 213 | # array up front and then just copy into it maybe? 214 | # but not in the tight loop, what if that causes the clicks? 215 | r = numpy.empty([NUM_CHANNELS, 0], dtype=NUMPY_DTYPE) 216 | for chunk in data: 217 | r = numpy.concatenate((r, chunk), axis=1) 218 | 219 | sample_width = p.get_sample_size(FORMAT) 220 | stream.stop_stream() 221 | stream.close() 222 | p.terminate() 223 | 224 | if snd_started or allow_empty_return: 225 | return sample_width, r, release_time 226 | else: 227 | return sample_width, None, release_time 228 | 229 | 230 | def record_to_file( 231 | path, 232 | limit, 233 | after_start=None, 234 | on_time_up=None, 235 | sample_rate=SAMPLE_RATE 236 | ): 237 | sample_width, data, release_time = record( 238 | limit, 239 | after_start, 240 | on_time_up, 241 | sample_rate, 242 | ) 243 | if data is not None: 244 | save_to_file(path, sample_width, data, sample_rate) 245 | return path 246 | else: 247 | return None 248 | 249 | 250 | def save_to_file(path, sample_width, data, sample_rate=SAMPLE_RATE): 251 | wf = wave.open(path, 'wb') 252 | wf.setnchannels(NUM_CHANNELS) 253 | wf.setsampwidth(sample_width) 254 | wf.setframerate(sample_rate) 255 | 256 | flattened = numpy.asarray(data.flatten('F'), dtype=NUMPY_DTYPE) 257 | 258 | write_chunk_size = 512 259 | for chunk_start in xrange(0, len(flattened), write_chunk_size): 260 | chunk = flattened[chunk_start:chunk_start + write_chunk_size] 261 | packstring = '<' + ('h' * len(chunk)) 262 | wf.writeframes(pack(packstring, *chunk)) 263 | wf.close() 264 | 265 | 266 | if __name__ == '__main__': 267 | print record_to_file('./demo.wav', sys.argv[1] if sys.argv[1] else None) 268 | print("done - result written to demo.wav") 269 | -------------------------------------------------------------------------------- /lib/send_notes.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from tqdm import tqdm 4 | from record import save_to_file, get_input_device_name_by_index 5 | from sfzparser import SFZFile, Region 6 | from pitch import compute_zones, Zone 7 | from utils import trim_data, \ 8 | note_name, \ 9 | first_non_none, \ 10 | warn_on_clipping 11 | from constants import bit_depth, SAMPLE_RATE 12 | from volume_leveler import level_volume 13 | from flacize import flacize_after_sampling 14 | from loop import find_loop_points 15 | from midi_helpers import Midi, all_notes_off, \ 16 | open_midi_port, \ 17 | open_midi_port_by_index, \ 18 | set_program_number, \ 19 | CHANNEL_OFFSET 20 | from audio_helpers import sample_threshold_from_noise_floor, \ 21 | generate_sample, \ 22 | check_for_clipping 23 | 24 | VELOCITIES = [ 25 | 15, 44, 26 | 63, 79, 95, 111, 27 | 127 28 | ] 29 | MAX_ATTEMPTS = 8 30 | PRINT_SILENCE_WARNINGS = False 31 | 32 | PORTAMENTO_PRESAMPLE_LIMIT = 2.0 33 | PORTAMENTO_PRESAMPLE_WAIT = 1.0 34 | 35 | # percentage - how much left/right delta can we tolerate? 36 | VOLUME_DIFF_THRESHOLD = 0.01 37 | 38 | 39 | def filename_for(note, velocity): 40 | return '%s_v%s.aif' % (note_name(note), velocity) 41 | 42 | 43 | def generate_region(zone, velocity, velocities, loop=None): 44 | """Generate an SFZ region.""" 45 | velocity_index = velocities.index(velocity) 46 | if velocity_index > 0: 47 | lovel = velocities[velocity_index - 1] + 1 48 | else: 49 | lovel = 1 50 | hivel = velocity 51 | 52 | # Note: the velcurve should be: 53 | # Velocity | Amplitude 54 | # --------------------- 55 | # hivel | 1.0 (sample at full volume) 56 | # ... | linear mapping 57 | # lovel + 1 | (next lowest layer's dB / this layer's dB) 58 | 59 | attributes = { 60 | 'lovel': lovel, 61 | 'hivel': hivel, 62 | 'ampeg_release': 1, 63 | 'sample': filename_for(zone.center, velocity), 64 | 'offset': 0, 65 | 'lokey': zone.low, 66 | 'hikey': zone.high, 67 | 'pitch_keycenter': zone.center, 68 | } 69 | if loop is not None: 70 | attributes.update({ 71 | 'loop_mode': 'loop_continuous', 72 | 'loop_start': loop[0], 73 | 'loop_end': loop[1], 74 | }) 75 | return Region(attributes) 76 | 77 | 78 | def all_notes(zones_to_sample, velocities, ascending=False): 79 | """Generate all the (zone, velocity, index) tuples before sampling.""" 80 | for zone in (zones_to_sample if ascending else reversed(zones_to_sample)): 81 | for i, velocity in enumerate(velocities): 82 | yield zone, velocity, (i == len(velocities) - 1) 83 | 84 | 85 | CLICK_RETRIES = 5 86 | 87 | 88 | def generate_and_save_sample( 89 | limit, 90 | midiout, 91 | zone, 92 | velocity, 93 | midi_channel, 94 | filename, 95 | threshold, 96 | velocity_levels, 97 | looping_enabled=False, 98 | print_progress=False, 99 | audio_interface_name=None, 100 | sample_rate=SAMPLE_RATE, 101 | ): 102 | while True: 103 | sample_width, data, release_time = generate_sample( 104 | limit=limit, 105 | midiout=midiout, 106 | note=zone.center, 107 | velocity=velocity, 108 | midi_channel=midi_channel, 109 | threshold=threshold, 110 | print_progress=print_progress, 111 | audio_interface_name=audio_interface_name, 112 | sample_rate=sample_rate, 113 | ) 114 | 115 | if data is not None: 116 | data = trim_data(data, threshold * 10, threshold) 117 | warn_on_clipping(data) 118 | if looping_enabled: 119 | loop = find_loop_points(data, SAMPLE_RATE) 120 | else: 121 | loop = None 122 | save_to_file(filename, sample_width, data, sample_rate) 123 | return generate_region(zone, velocity, velocity_levels, loop) 124 | else: 125 | return None 126 | 127 | 128 | def sample_program( 129 | output_folder='foo', 130 | low_key=21, 131 | high_key=109, 132 | max_attempts=8, 133 | midi_channel=1, 134 | midi_port_name=None, 135 | midi_port_index=None, 136 | audio_interface_name=None, 137 | audio_interface_index=None, 138 | cc_before=None, 139 | program_number=None, 140 | cc_after=None, 141 | flac=True, 142 | velocity_levels=VELOCITIES, 143 | key_range=1, 144 | cleanup_aif_files=True, 145 | limit=None, 146 | looping_enabled=False, 147 | print_progress=False, 148 | has_portamento=False, 149 | sample_asc=False, 150 | sample_rate=SAMPLE_RATE, 151 | ): 152 | if midi_port_name: 153 | midiout = open_midi_port(midi_port_name) 154 | else: 155 | midiout = open_midi_port_by_index(midi_port_index) 156 | 157 | if not audio_interface_name: 158 | audio_interface_name = get_input_device_name_by_index( 159 | audio_interface_index) 160 | 161 | path_prefix = output_folder 162 | if program_number is not None: 163 | print("Sampling program number %d into path %s" % ( 164 | program_number, output_folder)) 165 | else: 166 | print("Sampling into path %s" % (output_folder)) 167 | 168 | try: 169 | os.mkdir(path_prefix) 170 | except OSError: 171 | pass 172 | 173 | sfzfile = os.path.join(path_prefix, 'file.sfz') 174 | try: 175 | regions = sum([group.regions 176 | for group in SFZFile(open(sfzfile).read()).groups], 177 | []) 178 | regions = [region for region in regions if region.exists(path_prefix)] 179 | except IOError: 180 | regions = [] 181 | 182 | midi = Midi(midiout, channel=midi_channel) 183 | for cc in cc_before or []: # Send out MIDI controller changes 184 | midi.cc(cc[0], cc[1]) 185 | set_program_number(midiout, midi_channel, program_number) 186 | for cc in cc_after or []: # Send out MIDI controller changes 187 | midi.cc(cc[0], cc[1]) 188 | 189 | threshold = sample_threshold_from_noise_floor( 190 | bit_depth, 191 | audio_interface_name 192 | ) 193 | 194 | check_for_clipping( 195 | midiout, 196 | midi_channel, 197 | threshold, 198 | bit_depth, 199 | audio_interface_name 200 | ) 201 | 202 | # Remove repeated velocity levels that might exist in user input 203 | temp_vel = {int(v) for v in velocity_levels} 204 | # Sort velocity levels ascending 205 | velocity_levels = sorted(temp_vel) 206 | 207 | groups = [] 208 | note_regions = [] 209 | 210 | zones_to_sample = compute_zones( 211 | Zone(low=low_key, high=high_key), step=key_range) 212 | 213 | for zone, velocity, done_note in tqdm(list(all_notes( 214 | zones_to_sample, 215 | velocity_levels, 216 | sample_asc 217 | ))): 218 | already_sampled_region = first_non_none([ 219 | region for region in regions 220 | if region.attributes['hivel'] == str(velocity) and 221 | region.attributes.get( 222 | 'key', region.attributes.get( 223 | 'pitch_keycenter', None 224 | )) == str(zone.center)]) 225 | if already_sampled_region is None: 226 | filename = os.path.join( 227 | path_prefix, filename_for(zone.center, velocity)) 228 | 229 | if print_progress: 230 | print("Sampling %s at velocity %s..." % ( 231 | note_name(zone.center), velocity)) 232 | 233 | if has_portamento: 234 | sample_width, data, release_time = generate_sample( 235 | limit=PORTAMENTO_PRESAMPLE_LIMIT, 236 | midiout=midiout, 237 | note=zone.center, 238 | velocity=velocity, 239 | midi_channel=midi_channel, 240 | threshold=threshold, 241 | print_progress=print_progress, 242 | audio_interface_name=audio_interface_name, 243 | sample_rate=sample_rate, 244 | ) 245 | time.sleep(PORTAMENTO_PRESAMPLE_WAIT) 246 | 247 | for attempt in xrange(0, MAX_ATTEMPTS): 248 | try: 249 | region = generate_and_save_sample( 250 | limit=limit, 251 | midiout=midiout, 252 | zone=zone, 253 | velocity=velocity, 254 | midi_channel=midi_channel, 255 | filename=filename, 256 | threshold=threshold, 257 | velocity_levels=velocity_levels, 258 | looping_enabled=looping_enabled, 259 | print_progress=print_progress, 260 | audio_interface_name=audio_interface_name, 261 | sample_rate=sample_rate, 262 | ) 263 | if region: 264 | regions.append(region) 265 | note_regions.append(region) 266 | with open(sfzfile, 'w') as file: 267 | file.write("\n".join([str(r) for r in regions])) 268 | elif PRINT_SILENCE_WARNINGS: 269 | print("Got no sound for %s at velocity %s." % ( 270 | note_name(zone.center), velocity)) 271 | except IOError: 272 | pass 273 | else: 274 | break 275 | else: 276 | print("Could not sample %s at vel %s: too many IOErrors." % ( 277 | note_name(zone.center), velocity)) 278 | else: 279 | note_regions.append(already_sampled_region) 280 | 281 | if done_note and len(note_regions) > 0: 282 | groups.append(level_volume(note_regions, output_folder)) 283 | note_regions = [] 284 | 285 | # Write the volume-leveled output: 286 | with open(sfzfile + '.leveled.sfz', 'w') as file: 287 | file.write("\n".join([str(group) for group in groups])) 288 | 289 | if flac: 290 | # Do a FLAC compression pass afterwards 291 | # TODO: Do this after each note if possible 292 | # would require graceful re-parsing of FLAC-combined regions 293 | flacize_after_sampling( 294 | output_folder, 295 | groups, 296 | sfzfile, 297 | cleanup_aif_files=cleanup_aif_files, 298 | ) 299 | -------------------------------------------------------------------------------- /lib/sfzparser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | comments = re.compile(r'//.*$', re.M) 5 | lookfor = re.compile(r'<(\w+)>|(\w+)=([^\s]+)') 6 | 7 | 8 | class SFZFile(object): 9 | def __init__(self, text=None): 10 | if text: 11 | self.groups = self.parse(text) 12 | else: 13 | self.groups = [] 14 | 15 | def parse(self, text): 16 | groups = [] 17 | groupdata = {} 18 | current_group_regions = [] 19 | regiondata = {} 20 | 21 | text = re.sub(comments, '', text) 22 | 23 | current = None 24 | 25 | for m in re.finditer(lookfor, text): 26 | if m.group(1) in ['group', 'region']: 27 | if m.group(1) == 'group': 28 | if groupdata != {}: 29 | if regiondata != {}: 30 | region = Region(regiondata) 31 | current_group_regions.append(region) 32 | regiondata = {} 33 | group = Group(groupdata, current_group_regions) 34 | groups.append(group) 35 | groupdata = {} 36 | current_group_regions = [] 37 | current = groupdata 38 | elif m.group(1) == 'region': 39 | if regiondata != {}: 40 | region = Region(regiondata) 41 | current_group_regions.append(region) 42 | regiondata = {} 43 | current = regiondata 44 | else: 45 | current[m.group(2)] = m.group(3) 46 | 47 | if len(groups) == 0: 48 | if regiondata != {}: 49 | current_group_regions.append(Region(regiondata)) 50 | return [Group({}, current_group_regions)] 51 | else: 52 | groups[-1].regions.append(Region(regiondata)) 53 | return groups 54 | 55 | 56 | class Group(object): 57 | def __init__(self, attributes, regions): 58 | self.attributes = attributes 59 | self.regions = regions 60 | 61 | def flattened_regions(self): 62 | return [region.merge(self.attributes) for region in self.regions] 63 | 64 | def just_group(self): 65 | return "\n".join( 66 | [""] + 67 | ['%s=%s' % (k, v) for k, v in self.attributes.iteritems()] 68 | ) 69 | 70 | def __repr__(self): 71 | return "" % ( 72 | repr(self.attributes), 73 | repr(self.regions) 74 | ) 75 | 76 | def __str__(self): 77 | return self.just_group() + "\n" + "\n\n".join([ 78 | str(r) for r in self.regions 79 | ]) 80 | 81 | 82 | class Region(object): 83 | def __init__(self, attributes): 84 | self.attributes = attributes 85 | 86 | def __repr__(self): 87 | return "" % ( 88 | repr(self.attributes) 89 | ) 90 | 91 | def __str__(self): 92 | return "\n".join( 93 | [""] + 94 | ['%s=%s' % (k, v) for k, v in self.attributes.iteritems()] 95 | ) 96 | 97 | def exists(self, root=None): 98 | sample_path = self.attributes['sample'] 99 | if root: 100 | sample_path = os.path.join(root, sample_path) 101 | return os.path.isfile(sample_path) 102 | 103 | def without_attributes(self, discard=lambda x: False): 104 | return Region(dict([ 105 | (k, v) 106 | for k, v in self.attributes.iteritems() 107 | if not discard(k) 108 | ])) 109 | 110 | def merge(self, other_attrs): 111 | return Region(dict( 112 | (k, v) 113 | for d in [self.attributes, other_attrs] 114 | for k, v in d.iteritems() 115 | )) 116 | 117 | 118 | if __name__ == "__main__": 119 | import argparse 120 | 121 | parser = argparse.ArgumentParser(description='parse SFZ files') 122 | parser.add_argument('files', type=str, help='files to parse', nargs='+') 123 | args = parser.parse_args() 124 | 125 | for fn in args.files: 126 | file = SFZFile(open(fn).read()) 127 | for group in file.groups: 128 | print group 129 | -------------------------------------------------------------------------------- /lib/spectrogram.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | """ This work is licensed under a Creative Commons Attribution 3.0 4 | Unported License. 5 | Frank Zalkow, 2012-2013 """ 6 | 7 | import sys 8 | import numpy as np 9 | from matplotlib import pyplot as plt 10 | from numpy.lib import stride_tricks 11 | from wavio import read_wave_file 12 | 13 | 14 | def stft(sig, frame_size, overlap_fac=0.5, window=np.hanning): 15 | """ short time fourier transform of audio signal """ 16 | win = window(frame_size) 17 | hop_size = int(frame_size - np.floor(overlap_fac * frame_size)) 18 | 19 | # zeros at beginning (thus center of 1st window should be for sample nr. 0) 20 | samples = np.append(np.zeros(np.floor(frame_size / 2.0)), sig) 21 | # cols for windowing 22 | cols = np.ceil((len(samples) - frame_size) / float(hop_size)) + 1 23 | # zeros at end (thus samples can be fully covered by frames) 24 | samples = np.append(samples, np.zeros(frame_size)) 25 | 26 | frames = stride_tricks.as_strided( 27 | samples, 28 | shape=(cols, frame_size), 29 | strides=( 30 | samples.strides[0] * hop_size, 31 | samples.strides[0] 32 | ) 33 | ).copy() 34 | 35 | frames *= win 36 | 37 | return np.fft.rfft(frames) 38 | 39 | 40 | def logscale_spec(spec, sr=44100, factor=20.): 41 | """ scale frequency axis logarithmically """ 42 | timebins, freqbins = np.shape(spec) 43 | 44 | scale = np.linspace(0, 1, freqbins) ** factor 45 | scale *= (freqbins - 1) / max(scale) 46 | scale = np.unique(np.round(scale)) 47 | 48 | # create spectrogram with new freq bins 49 | newspec = np.complex128(np.zeros([timebins, len(scale)])) 50 | for i in range(0, len(scale)): 51 | if i == len(scale) - 1: 52 | newspec[:, i] = np.sum(spec[:, scale[i]:], axis=1) 53 | else: 54 | newspec[:, i] = np.sum(spec[:, scale[i]:scale[i + 1]], axis=1) 55 | 56 | # list center freq of bins 57 | allfreqs = np.abs(np.fft.fftfreq(freqbins * 2, 1. / sr)[:freqbins + 1]) 58 | freqs = [] 59 | for i in range(0, len(scale)): 60 | if i == len(scale) - 1: 61 | freqs += [np.mean(allfreqs[scale[i]:])] 62 | else: 63 | freqs += [np.mean(allfreqs[scale[i]:scale[i + 1]])] 64 | 65 | return newspec, freqs 66 | 67 | 68 | def plotstft(samplerate, 69 | samples, 70 | binsize=2 ** 10, 71 | plotpath=None, 72 | colormap="jet"): 73 | """ plot spectrogram""" 74 | s = stft(samples, binsize) 75 | 76 | sshow, freq = logscale_spec(s, factor=1.0, sr=samplerate) 77 | ims = 20. * np.log10(np.abs(sshow) / 10e-6) # amplitude to decibel 78 | 79 | timebins, freqbins = np.shape(ims) 80 | 81 | plt.figure(figsize=(15, 7.5)) 82 | plt.imshow( 83 | np.transpose(ims), 84 | origin="lower", 85 | aspect="auto", 86 | cmap=colormap, 87 | interpolation="none" 88 | ) 89 | plt.colorbar() 90 | 91 | plt.xlabel("time (s)") 92 | plt.ylabel("frequency (hz)") 93 | plt.xlim([0, timebins - 1]) 94 | plt.ylim([0, freqbins]) 95 | 96 | xlocs = np.float32(np.linspace(0, timebins - 1, 5)) 97 | plt.xticks(xlocs, [ 98 | "%.02f" % l 99 | for l in ( 100 | ((xlocs * len(samples) / timebins) + (0.5 * binsize)) / samplerate 101 | ) 102 | ]) 103 | ylocs = np.int16(np.round(np.linspace(0, freqbins - 1, 10))) 104 | plt.yticks(ylocs, ["%.02f" % freq[i] for i in ylocs]) 105 | 106 | if plotpath: 107 | plt.savefig(plotpath, bbox_inches="tight") 108 | else: 109 | plt.show() 110 | 111 | plt.clf() 112 | 113 | if __name__ == "__main__": 114 | stereo = read_wave_file(sys.argv[1]) 115 | left = stereo[0] 116 | plotstft(48000, left) 117 | -------------------------------------------------------------------------------- /lib/starts_with_click.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from wavio import read_wave_file 3 | from constants import bit_depth 4 | 5 | default_threshold_samples = (0.001 * float(2 ** (bit_depth - 1))) 6 | 7 | 8 | def starts_with_click(filename, threshold_samples=default_threshold_samples): 9 | sample_data = read_wave_file(filename) 10 | return (abs(sample_data[0][0]) > threshold_samples or 11 | abs(sample_data[0][1]) > threshold_samples) 12 | 13 | if __name__ == "__main__": 14 | if starts_with_click(sys.argv[1]): 15 | sys.exit(0) 16 | else: 17 | sys.exit(1) 18 | -------------------------------------------------------------------------------- /lib/truncate.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from wavio import read_wave_file 3 | from utils import start_of, end_of 4 | 5 | 6 | def chop(aif): 7 | file = read_wave_file(aif) 8 | start, end = min([start_of(chan) for chan in file]), \ 9 | max([end_of(chan) for chan in file]) 10 | print aif, start, end, float(end) / len(file[0]) 11 | 12 | # outfile = aif + '.chopped.aif' 13 | # r = wave.open(aif, 'rb') 14 | # w = wave.open(outfile, 'wb') 15 | # w.setnchannels(r.getnchannels()) 16 | # w.setsampwidth(r.getsampwidth()) 17 | # w.setframerate(r.getframerate()) 18 | 19 | # # Seek forward to the start point 20 | # r.readframes(start) 21 | 22 | # # Copy the frames from in to out 23 | # w.writeframes(r.readframes(end - start)) 24 | # r.close() 25 | # w.close() 26 | 27 | # plt.plot(file[0][:(44100 * 2)]) 28 | # plt.axvline(start) 29 | # plt.axvline(end) 30 | # plt.show() 31 | 32 | 33 | if __name__ == "__main__": 34 | chop(sys.argv[1]) 35 | -------------------------------------------------------------------------------- /lib/utils.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import numpy 3 | import math 4 | from numpy import inf 5 | 6 | from constants import default_silence_threshold, bit_depth 7 | from collections import defaultdict 8 | 9 | 10 | NOTE_NAMES = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B') 11 | C0_OFFSET = 12 12 | 13 | 14 | def note_name(note): 15 | """Return a note name from the MIDI note number.""" 16 | from_c = int(int(note) - C0_OFFSET) 17 | note_name = NOTE_NAMES[(from_c % 12)] 18 | octave_number = (from_c / 12) 19 | return "%s%d" % (note_name, octave_number) 20 | 21 | 22 | def note_number(note_name): 23 | """Return the MIDI key number from a note name. 24 | 25 | The first character of ``note_name`` can be in lower or upper case. 26 | """ 27 | name = note_name[0].upper() 28 | if len(note_name) > 2: 29 | name += note_name[1] 30 | octave_number = int(note_name[-1]) 31 | return C0_OFFSET + NOTE_NAMES.index(name) + (12 * octave_number) 32 | 33 | 34 | def two_ints(value): 35 | """Type for argparse. Demands 2 integers separated by a comma.""" 36 | key, val = value.split(',') 37 | return (int(key), int(val)) 38 | 39 | 40 | def warn_on_clipping(data, threshold=0.9999): 41 | if numpy.amax(numpy.absolute(data)) > ((2 ** (bit_depth - 1)) * threshold): 42 | print("WARNING: Clipping detected!") 43 | 44 | 45 | def sample_value_to_db(value, bit_depth=bit_depth): 46 | if value == 0: 47 | return -inf 48 | return 20. * math.log(float(abs(value)) / (2 ** (bit_depth - 1)), 10) 49 | 50 | 51 | def percent_to_db(percent): 52 | if percent == 0: 53 | return -inf 54 | return 20. * math.log(percent, 10) 55 | 56 | 57 | def dbfs_as_percent(dbfs, bit_depth=bit_depth): 58 | """ 59 | Represent a dBFS value as a percentage, which can be used to render 60 | a VU meter. Note this is _not_ the inverse of percent_to_db. 61 | """ 62 | minimum_dbfs_value = sample_value_to_db(1, bit_depth) 63 | return min(1., max(0., (dbfs / -minimum_dbfs_value) + 1)) 64 | 65 | 66 | def trim_data( 67 | data, 68 | start_threshold=default_silence_threshold, 69 | end_threshold=default_silence_threshold 70 | ): 71 | start, end = min([start_of(chan, start_threshold) for chan in data]), \ 72 | max([end_of(chan, end_threshold) for chan in data]) 73 | 74 | return data[0:, start:end] 75 | 76 | 77 | def trim_mono_data( 78 | data, 79 | start_threshold=default_silence_threshold, 80 | end_threshold=default_silence_threshold 81 | ): 82 | start, end = start_of(data, start_threshold), end_of(data, end_threshold) 83 | return data[start:end] 84 | 85 | 86 | def normalized(list): 87 | return list.astype(numpy.float32) / float(numpy.amax(numpy.abs(list))) 88 | 89 | 90 | def start_of(list, threshold=default_silence_threshold, samples_before=1): 91 | if int(threshold) != threshold: 92 | threshold = threshold * float(2 ** (bit_depth - 1)) 93 | index = numpy.argmax(numpy.absolute(list) > threshold) 94 | if index > (samples_before - 1): 95 | return index - samples_before 96 | else: 97 | return 0 98 | 99 | 100 | def end_of(list, threshold=default_silence_threshold, samples_after=1): 101 | if int(threshold) != threshold: 102 | threshold = threshold * float(2 ** (bit_depth - 1)) 103 | rev_index = numpy.argmax( 104 | numpy.flipud(numpy.absolute(list)) > threshold 105 | ) 106 | if rev_index > (samples_after - 1): 107 | return len(list) - (rev_index - samples_after) 108 | else: 109 | return len(list) 110 | 111 | 112 | def first_non_none(list): 113 | try: 114 | return next(item for item in list if item is not None) 115 | except StopIteration: 116 | return None 117 | 118 | 119 | def group_by_attr(data, attrs): 120 | if not isinstance(attrs, list): 121 | attrs = [attrs] 122 | groups = defaultdict(list) 123 | for k, g in itertools.groupby( 124 | data, 125 | lambda x: first_non_none([ 126 | x.attributes.get(attr, None) for attr in attrs 127 | ]) 128 | ): 129 | groups[k].extend(list(g)) 130 | return groups 131 | -------------------------------------------------------------------------------- /lib/volume_leveler.py: -------------------------------------------------------------------------------- 1 | import numpy 2 | import argparse 3 | from constants import bit_depth 4 | from sfzparser import SFZFile, Group 5 | from wavio import read_wave_file 6 | from utils import group_by_attr 7 | from flacize import full_path 8 | from itertools import tee, izip 9 | 10 | 11 | def pairwise(iterable): 12 | "s -> (s0,s1), (s1,s2), (s2, s3), ..." 13 | a, b = tee(iterable) 14 | next(b, None) 15 | return izip(a, b) 16 | 17 | 18 | def max_amp(filename): 19 | return numpy.amax(read_wave_file(filename)) / (2. ** (bit_depth - 1)) 20 | 21 | 22 | def peak_rms(data, window_size=480, limits=960): 23 | index = max([numpy.argmax(channel) for channel in data]) 24 | maxlimit = max([len(channel) for channel in data]) 25 | max_so_far = 0 26 | for i in xrange( 27 | max(index - limits, (window_size / 2)), 28 | min(index + limits, maxlimit - (window_size / 2)) 29 | ): 30 | for channel in data: 31 | window = channel[i - (window_size / 2):i + (window_size / 2)] 32 | if len(window) == 0: 33 | raise Exception("Cannot take mean of empty slice! Channel " 34 | "size %d, index %d, window size %d" % ( 35 | len(channel), i, window_size 36 | )) 37 | it_max = numpy.sqrt( 38 | numpy.mean(window.astype(numpy.float) ** 2) 39 | ) / (2. ** (bit_depth - 1)) 40 | if it_max > max_so_far: 41 | max_so_far = it_max 42 | return max_so_far 43 | 44 | 45 | REMOVE_ATTRS = ['amp_velcurve_127', 'amp_velcurve_0', 'amp_veltrack'] 46 | 47 | 48 | def level_volume(regions, dirname): 49 | if len(regions) == 0: 50 | return None 51 | 52 | velcurve = {} 53 | 54 | velsorted = list(reversed( 55 | sorted(regions, key=lambda x: int(x.attributes['hivel'])) 56 | )) 57 | for high, low in pairwise(velsorted): 58 | try: 59 | diff = ( 60 | peak_rms( 61 | read_wave_file( 62 | full_path(dirname, low.attributes['sample']) 63 | ) 64 | ) / 65 | peak_rms( 66 | read_wave_file( 67 | full_path(dirname, high.attributes['sample']) 68 | ) 69 | ) 70 | ) 71 | except ZeroDivisionError: 72 | print "Got ZeroDivisionError with high sample path: %s" % \ 73 | high.attributes['sample'] 74 | raise 75 | for attr in REMOVE_ATTRS: 76 | if attr in high.attributes: 77 | del high.attributes[attr] 78 | velcurve.update({ 79 | ('amp_velcurve_%d' % 80 | int(high.attributes['hivel'])): 1, 81 | ('amp_velcurve_%d' % 82 | (int(high.attributes['lovel']) + 1)): diff, 83 | }) 84 | # print the last region that didn't have a lower counterpart 85 | low = velsorted[-1] 86 | for attr in REMOVE_ATTRS: 87 | if attr in low.attributes: 88 | del low.attributes[attr] 89 | velcurve.update({ 90 | ('amp_velcurve_%d' % 91 | int(low.attributes['hivel'])): 1, 92 | ('amp_velcurve_%d' % 93 | (int(low.attributes['lovel']) + 1)): 0, 94 | }) 95 | return Group(velcurve, velsorted) 96 | 97 | 98 | if __name__ == "__main__": 99 | parser = argparse.ArgumentParser( 100 | description='volume-level sfz files with non-normalized samples' 101 | ) 102 | parser.add_argument('files', type=str, help='files to process', nargs='+') 103 | args = parser.parse_args() 104 | 105 | for filename in args.files: 106 | sfz = SFZFile(open(filename).read()) 107 | regions = sum([group.regions for group in sfz.groups], []) 108 | for key, regions in group_by_attr(regions, 'key').iteritems(): 109 | print level_volume(regions) 110 | -------------------------------------------------------------------------------- /lib/wavio.py: -------------------------------------------------------------------------------- 1 | import os 2 | import wave 3 | import numpy 4 | import subprocess 5 | 6 | from constants import NUMPY_DTYPE 7 | 8 | 9 | def read_flac_file(filename, use_numpy=False): 10 | tempfile = filename + '.tmp.wav' 11 | commandline = [ 12 | 'ffmpeg', 13 | '-y', 14 | '-i', 15 | filename, 16 | tempfile 17 | ] 18 | # sys.stderr.write("Calling '%s'...\n" % ' '.join(commandline)) 19 | subprocess.call( 20 | commandline, 21 | stdout=open('/dev/null', 'w'), 22 | stderr=open('/dev/null', 'w') 23 | ) 24 | result = read_wave_file(tempfile, use_numpy) 25 | os.unlink(tempfile) 26 | return result 27 | 28 | 29 | def read_wave_file(filename, use_numpy=False): 30 | try: 31 | w = wave.open(filename) 32 | a = numpy.fromstring(w.readframes(9999999999), dtype=NUMPY_DTYPE) 33 | if use_numpy: 34 | return numpy.reshape(a, (w.getnchannels(), -1), 'F') 35 | else: 36 | return [ 37 | a[i::w.getnchannels()] 38 | for i in xrange(w.getnchannels()) 39 | ] 40 | except wave.Error: 41 | print "Could not open %s" % filename 42 | raise 43 | -------------------------------------------------------------------------------- /record.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Command-line interface for SampleScanner.""" 4 | 5 | import argparse 6 | from lib.utils import note_number, two_ints 7 | from lib.send_notes import sample_program, VELOCITIES, MAX_ATTEMPTS 8 | 9 | 10 | if __name__ == '__main__': 11 | parser = argparse.ArgumentParser( 12 | description='create SFZ files from external audio devices' 13 | ) 14 | sampling_options = parser.add_argument_group('Sampling Options') 15 | sampling_options.add_argument( 16 | '--cc-before', type=two_ints, help='Send MIDI CC before the program ' 17 | 'change. Put comma between CC# and value. Example: ' 18 | '--cc 0,127 "64,65"', nargs='*') 19 | sampling_options.add_argument( 20 | '--cc-after', type=two_ints, help='Send MIDI CC after the program ' 21 | 'change. Put comma between CC# and value. Example: ' 22 | '--cc 0,127 "64,65"', nargs='*') 23 | sampling_options.add_argument( 24 | '--program-number', type=int, 25 | help='switch to a program number before recording') 26 | sampling_options.add_argument( 27 | '--low-key', type=note_number, default=21, 28 | help='key to start sampling from (key name, octave number)') 29 | sampling_options.add_argument( 30 | '--high-key', type=note_number, default=109, 31 | help='key to stop sampling at (key name, octave number)') 32 | sampling_options.add_argument( 33 | '--velocity-levels', type=int, default=VELOCITIES, nargs='+', 34 | help='velocity levels (in [1, 127]) to sample') 35 | sampling_options.add_argument( 36 | '--key-skip', type=int, default=1, dest='key_range', 37 | help='number of keys covered by one sample') 38 | sampling_options.add_argument( 39 | '--max-attempts', type=int, default=MAX_ATTEMPTS, 40 | help='maximum number of tries to resample a note') 41 | sampling_options.add_argument( 42 | '--limit', type=float, default=45, 43 | help='length in seconds of longest sample') 44 | sampling_options.add_argument( 45 | '--has-portamento', action='store_true', dest='has_portamento', 46 | help='play each note once before sampling to avoid ' 47 | 'portamento sweeps between notes') 48 | sampling_options.add_argument( 49 | '--sample-asc', action='store_true', dest='sample_asc', 50 | help='sample notes from low to high (default false)') 51 | 52 | output_options = parser.add_argument_group('Output Options') 53 | output_options.add_argument( 54 | 'output_folder', type=str, 55 | help='name of output folder') 56 | output_options.add_argument( 57 | '--no-flac', action='store_false', dest='flac', 58 | help="don't compress output to flac samples") 59 | output_options.add_argument( 60 | '--no-delete', action='store_false', dest='cleanup_aif_files', 61 | help='leave temporary .aif files in place after flac compression') 62 | output_options.add_argument( 63 | '--loop', action='store_true', dest='looping_enabled', 64 | help='attempt to loop sounds (should only be used ' 65 | 'with sounds with infinite sustain)') 66 | 67 | io_options = parser.add_argument_group('MIDI/Audio IO Options') 68 | io_options.add_argument( 69 | '--midi-port-name', type=str, 70 | help='name of MIDI device to use') 71 | io_options.add_argument( 72 | '--midi-port-index', type=int, default=-1, 73 | help='index of MIDI device to use') 74 | io_options.add_argument( 75 | '--midi-channel', type=int, default=1, 76 | help='MIDI channel to send messages on') 77 | io_options.add_argument( 78 | '--audio-interface-name', type=str, 79 | help='name of audio input device to use') 80 | io_options.add_argument( 81 | '--audio-interface-index', type=int, default=-1, 82 | help='index of audio input device to use') 83 | io_options.add_argument( 84 | '--sample-rate', type=int, default=48000, 85 | help='sample rate to use. audio interface must support this rate.') 86 | 87 | misc_options = parser.add_argument_group('Misc Options') 88 | misc_options.add_argument( 89 | '--print-progress', action='store_true', dest='print_progress', 90 | help='show text-based VU meters in terminal (default false, ' 91 | 'can cause audio artifacts)') 92 | 93 | args = parser.parse_args() 94 | 95 | sample_program( 96 | output_folder=args.output_folder, 97 | low_key=args.low_key, 98 | high_key=args.high_key, 99 | max_attempts=args.max_attempts, 100 | midi_channel=args.midi_channel, 101 | midi_port_name=args.midi_port_name, 102 | midi_port_index=args.midi_port_index, 103 | audio_interface_name=args.audio_interface_name, 104 | audio_interface_index=args.audio_interface_index, 105 | cc_before=args.cc_before, 106 | program_number=args.program_number, 107 | cc_after=args.cc_after, 108 | flac=args.flac, 109 | velocity_levels=args.velocity_levels, 110 | key_range=args.key_range, 111 | cleanup_aif_files=args.cleanup_aif_files, 112 | limit=args.limit, 113 | looping_enabled=args.looping_enabled, 114 | print_progress=args.print_progress, 115 | has_portamento=args.has_portamento, 116 | sample_asc=args.sample_asc, 117 | sample_rate=args.sample_rate, 118 | ) 119 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyAudio==0.2.9 2 | python-rtmidi==1.0.0 3 | numpy==1.12.1 4 | tqdm==4.8.4 5 | tabulate==0.7.7 6 | autopep8==0.9.6 7 | pep8==1.4.6 8 | -------------------------------------------------------------------------------- /samplescanner: -------------------------------------------------------------------------------- 1 | record.py -------------------------------------------------------------------------------- /tests/note_name_test.py: -------------------------------------------------------------------------------- 1 | from lib.utils import note_name, note_number 2 | 3 | 4 | def test_note_name(): 5 | assert 'C4' == note_name(60) 6 | assert 'Db4' == note_name(61) 7 | assert 'D4' == note_name(62) 8 | assert 'Eb4' == note_name(63) 9 | assert 'E4' == note_name(64) 10 | assert 'F4' == note_name(65) 11 | assert 'Gb4' == note_name(66) 12 | assert 'G4' == note_name(67) 13 | assert 'Ab4' == note_name(68) 14 | assert 'A4' == note_name(69) 15 | assert 'Bb4' == note_name(70) 16 | assert 'B4' == note_name(71) 17 | 18 | 19 | def test_note_number(): 20 | assert 60 == note_number('C4') 21 | assert 60 == note_number('c4') 22 | assert 61 == note_number('Db4') 23 | assert 61 == note_number('db4') 24 | assert 62 == note_number('D4') 25 | assert 62 == note_number('d4') 26 | assert 63 == note_number('Eb4') 27 | assert 63 == note_number('eb4') 28 | assert 64 == note_number('E4') 29 | assert 64 == note_number('e4') 30 | assert 65 == note_number('F4') 31 | assert 65 == note_number('f4') 32 | assert 66 == note_number('Gb4') 33 | assert 66 == note_number('gb4') 34 | assert 67 == note_number('G4') 35 | assert 67 == note_number('g4') 36 | assert 68 == note_number('Ab4') 37 | assert 68 == note_number('ab4') 38 | assert 69 == note_number('A4') 39 | assert 69 == note_number('a4') 40 | assert 70 == note_number('Bb4') 41 | assert 70 == note_number('bb4') 42 | assert 71 == note_number('B4') 43 | assert 71 == note_number('b4') 44 | -------------------------------------------------------------------------------- /tests/pitch_test.py: -------------------------------------------------------------------------------- 1 | """A few tests for pitch.py.""" 2 | 3 | from lib.pitch import compute_zones, Zone 4 | 5 | 6 | def test_zones_with_step_1(): 7 | total = Zone(60, 61) 8 | assert [ 9 | Zone(low=60, high=60, center=60), 10 | Zone(low=61, high=61, center=61), 11 | ] == compute_zones(total, step=1) 12 | 13 | 14 | def test_zones_with_step_2(): 15 | total = Zone(0, 3) 16 | assert [ 17 | Zone(low=0, high=1, center=1), 18 | Zone(low=2, high=3, center=3), 19 | ] == compute_zones(total, step=2) 20 | 21 | 22 | def test_zones_with_step_2_partial(): 23 | total = Zone(0, 2) 24 | assert [ 25 | Zone(low=0, high=1, center=1), 26 | Zone(low=2, high=2, center=3), 27 | ] == compute_zones(total, step=2) 28 | 29 | 30 | def test_zones_with_step_3(): 31 | total = Zone(48, 55) 32 | assert [ 33 | Zone(low=48, center=49, high=50), 34 | Zone(low=51, center=52, high=53), 35 | Zone(low=54, center=55, high=55), 36 | ] == compute_zones(total, step=3) 37 | 38 | 39 | def test_zones_with_step_4(): 40 | total = Zone(48, 56) 41 | assert [ 42 | Zone(low=48, center=50, high=51), 43 | Zone(low=52, center=54, high=55), 44 | Zone(low=56, center=58, high=56), 45 | ] == compute_zones(total, step=4) 46 | -------------------------------------------------------------------------------- /tests/readme_test.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def readme_contentsof(name): 5 | with open('README.md') as readme: 6 | return ( 7 | readme.read() 8 | .split('```contentsof<%s>' % name)[1] 9 | .split('```')[0] 10 | .strip() 11 | ) 12 | 13 | 14 | def command_line_output_in_readme(): 15 | return readme_contentsof('samplescanner -h') 16 | 17 | 18 | def license_output_in_readme(): 19 | return readme_contentsof('cat LICENSE') 20 | 21 | 22 | def expected_command_line_output(): 23 | return subprocess.check_output( 24 | ['./samplescanner', '-h'], 25 | stderr=subprocess.STDOUT, 26 | ).strip() 27 | 28 | 29 | def expected_license(): 30 | with open('LICENSE') as license: 31 | return license.read().strip() 32 | 33 | 34 | def test_readme_contains_proper_command_line_output(): 35 | assert command_line_output_in_readme() == expected_command_line_output() 36 | 37 | 38 | def test_readme_contains_content_of_license(): 39 | assert license_output_in_readme() == expected_license() 40 | -------------------------------------------------------------------------------- /tests/utils_test.py: -------------------------------------------------------------------------------- 1 | from lib.utils import sample_value_to_db, percent_to_db, dbfs_as_percent 2 | 3 | 4 | def test_sample_value_to_db(): 5 | assert str(sample_value_to_db(0)) == "-inf" 6 | assert sample_value_to_db(1) == -90.30899869919435 7 | 8 | 9 | def test_percent_to_db(): 10 | assert percent_to_db(0.000030518) == -90.30887862628592 11 | assert str(percent_to_db(0)) == "-inf" 12 | 13 | 14 | def test_dbfs_as_percent(): 15 | minimum_16_bit_dbfs = -90.30899869919435 16 | assert dbfs_as_percent(minimum_16_bit_dbfs) == 0 17 | assert dbfs_as_percent(0) == 1 18 | --------------------------------------------------------------------------------