├── tests ├── __init__.py ├── data │ ├── e_octaves.wav │ ├── e_major_chord.wav │ └── sunshine_riff.wav └── test_audio_transform.py ├── notebooks ├── fastai_audio ├── [util] Resample Dataset.ipynb ├── [util] Pitch Shift Dataset.ipynb ├── [util] Convert to Mono.ipynb └── utils.py ├── setup.cfg ├── fastai_audio ├── __init__.py ├── metrics.py ├── tta.py ├── learner.py ├── audio_clip.py ├── transform.py └── data.py ├── README.md ├── .gitignore └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /notebooks/fastai_audio: -------------------------------------------------------------------------------- 1 | ../fastai_audio/ -------------------------------------------------------------------------------- /tests/data/e_octaves.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhartquist/fastai_audio/HEAD/tests/data/e_octaves.wav -------------------------------------------------------------------------------- /tests/data/e_major_chord.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhartquist/fastai_audio/HEAD/tests/data/e_major_chord.wav -------------------------------------------------------------------------------- /tests/data/sunshine_riff.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jhartquist/fastai_audio/HEAD/tests/data/sunshine_riff.wav -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | filterwarnings = 4 | ignore::DeprecationWarning 5 | ignore::PendingDeprecationWarning 6 | -------------------------------------------------------------------------------- /fastai_audio/__init__.py: -------------------------------------------------------------------------------- 1 | from .audio_clip import * 2 | from .data import * 3 | from .learner import * 4 | from .metrics import * 5 | from .transform import * 6 | from .tta import * 7 | 8 | __all__ = [*audio_clip.__all__, *data.__all__, *learner.__all__, 9 | *metrics.__all__, *transform.__all__, *tta.__all__] 10 | -------------------------------------------------------------------------------- /fastai_audio/metrics.py: -------------------------------------------------------------------------------- 1 | from fastai.torch_core import * 2 | 3 | __all__ = ['mapk'] 4 | 5 | 6 | def mapk_np(preds, targs, k=3): 7 | preds = np.argsort(-preds, axis=1)[:, :k] 8 | score = 0. 9 | for i in range(k): 10 | num_hits = (preds[:, i] == targs).sum() 11 | score += num_hits * (1. / (i+1.)) 12 | score /= preds.shape[0] 13 | return score 14 | 15 | 16 | def mapk(preds, targs, k=3): 17 | return tensor(mapk_np(to_np(preds), to_np(targs), k)) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastai_audio 2 | 3 | This is an experimental and unofficial module add-on for the new [`fastai v1`](https://github.com/fastai/fastai) library. It adds the capability of audio classification to fastai by loading raw audio files and generating spectrograms on the fly. Please check out the notebooks directory for usage examples. 4 | 5 | The accompanying article can be found here: 6 | [Audio Classification using FastAI and On-the-Fly Frequency Transforms](https://medium.com/@johnhartquist/audio-classification-using-fastai-and-on-the-fly-frequency-transforms-4dbe1b540f89) 7 | 8 | ##### Related Links 9 | * [fastai v1 docs](https://docs.fastai.com) 10 | 11 | ##### Note: 12 | The [`fastai`](https://github.com/fastai/fastai) library is currently being developed rapidly, so this repo may quickly go out of date with new versions. 13 | 14 | #### Dependencies 15 | * python 3.6 16 | * fastai 1.0.43 17 | * librosa 0.6.2 18 | 19 | This repo was also heavily inspired by [torchaudio](http://pytorch.org/audio/), especially [`transforms.py`](http://pytorch.org/audio/_modules/torchaudio/transforms.html). 20 | -------------------------------------------------------------------------------- /fastai_audio/tta.py: -------------------------------------------------------------------------------- 1 | "Brings TTA (Test Time Functionality) to the `Learner` class. Use `learner.TTA()` instead" 2 | from fastai.torch_core import * 3 | from fastai.basic_train import * 4 | from fastai.basic_train import _loss_func2activ 5 | from fastai.basic_data import DatasetType 6 | 7 | __all__ = [] 8 | 9 | 10 | def _tta_only(learn:Learner, ds_type:DatasetType=DatasetType.Valid) -> Iterator[List[Tensor]]: 11 | "Computes the outputs for several augmented inputs for TTA" 12 | dl = learn.dl(ds_type) 13 | ds = dl.dataset 14 | old = ds.tfms 15 | augm_tfm = [o for o in learn.data.train_ds.tfms] 16 | try: 17 | pbar = master_bar(range(8)) 18 | for i in pbar: 19 | ds.tfms = augm_tfm 20 | yield get_preds(learn.model, dl, pbar=pbar, activ=_loss_func2activ(learn.loss_func))[0] 21 | finally: ds.tfms = old 22 | 23 | 24 | Learner.tta_only = _tta_only 25 | 26 | 27 | def _TTA(learn:Learner, beta:float=0.4, ds_type:DatasetType=DatasetType.Valid, with_loss:bool=False) -> Tensors: 28 | "Applies TTA to predict on `ds_type` dataset." 29 | preds,y = learn.get_preds(ds_type) 30 | all_preds = list(learn.tta_only(ds_type=ds_type)) 31 | avg_preds = torch.stack(all_preds).mean(0) 32 | if beta is None: return preds,avg_preds,y 33 | else: 34 | final_preds = preds*beta + avg_preds*(1-beta) 35 | if with_loss: 36 | return final_preds, y, calc_loss(final_preds, y, learn.loss_func) 37 | return final_preds, y 38 | 39 | 40 | Learner.TTA = _TTA 41 | -------------------------------------------------------------------------------- /fastai_audio/learner.py: -------------------------------------------------------------------------------- 1 | from fastai.torch_core import * 2 | from fastai.train import Learner 3 | 4 | from fastai.callbacks.hooks import num_features_model 5 | from fastai.vision import create_body, create_head 6 | from fastai.vision.learner import cnn_config, _resnet_split 7 | 8 | __all__ = ['create_cnn'] 9 | 10 | 11 | # copied from fastai.vision.learner, omitting unused args, 12 | # and adding channel summing of first convolutional layer 13 | def create_cnn(data, arch, pretrained=True, sum_channel_weights=True, **kwargs): 14 | meta = cnn_config(arch) 15 | body = create_body(arch, pretrained) 16 | 17 | # sum up the weights of in_channels axis, to reduce to single input channel 18 | # Suggestion by David Gutman 19 | # https://forums.fast.ai/t/black-and-white-images-on-vgg16/2479/2 20 | if sum_channel_weights: 21 | first_conv_layer = body[0] 22 | first_conv_weights = first_conv_layer.state_dict()['weight'] 23 | assert first_conv_weights.size(1) == 3 # RGB channels dim 24 | summed_weights = torch.sum(first_conv_weights, dim=1, keepdim=True) 25 | first_conv_layer.weight.data = summed_weights 26 | first_conv_layer.in_channels = 1 27 | 28 | nf = num_features_model(body) * 2 29 | head = create_head(nf, data.c, None, 0.5) 30 | model = nn.Sequential(body, head) 31 | learn = Learner(data, model, **kwargs) 32 | learn.split(meta['split']) 33 | if pretrained: 34 | learn.freeze() 35 | apply_init(model[1], nn.init.kaiming_normal_) 36 | return learn 37 | -------------------------------------------------------------------------------- /notebooks/[util] Resample Dataset.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from pathlib import Path\n", 10 | "from utils import resample_path" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": null, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "DATA = Path('data')" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": null, 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "AUDIO_SRC = DATA/'freesound/audio_44KHz/train'\n", 29 | "AUDIO_DST = DATA/'freesound/audio_22500_trimmed/train'\n", 30 | "\n", 31 | "resample_path(AUDIO_SRC, AUDIO_DST, sample_rate=22500, trim=True)" 32 | ] 33 | }, 34 | { 35 | "cell_type": "code", 36 | "execution_count": null, 37 | "metadata": {}, 38 | "outputs": [], 39 | "source": [ 40 | "AUDIO_SRC = DATA/'freesound/audio_44KHz/test'\n", 41 | "AUDIO_DST = DATA/'freesound/audio_22500_trimmed/test'\n", 42 | "\n", 43 | "resample_path(AUDIO_SRC, AUDIO_DST, sample_rate=22500, trim=True)" 44 | ] 45 | } 46 | ], 47 | "metadata": { 48 | "kernelspec": { 49 | "display_name": "Python 3", 50 | "language": "python", 51 | "name": "python3" 52 | }, 53 | "language_info": { 54 | "codemirror_mode": { 55 | "name": "ipython", 56 | "version": 3 57 | }, 58 | "file_extension": ".py", 59 | "mimetype": "text/x-python", 60 | "name": "python", 61 | "nbconvert_exporter": "python", 62 | "pygments_lexer": "ipython3", 63 | "version": "3.6.7" 64 | } 65 | }, 66 | "nbformat": 4, 67 | "nbformat_minor": 2 68 | } 69 | -------------------------------------------------------------------------------- /fastai_audio/audio_clip.py: -------------------------------------------------------------------------------- 1 | from fastai.torch_core import * 2 | from scipy.io import wavfile 3 | from IPython.display import display, Audio 4 | 5 | __all__ = ['AudioClip', 'open_audio'] 6 | 7 | 8 | class AudioClip(ItemBase): 9 | def __init__(self, signal, sample_rate): 10 | self.data = signal 11 | self.sample_rate = sample_rate 12 | 13 | def __str__(self): 14 | return '(duration={}s, sample_rate={:.1f}KHz)'.format( 15 | self.duration, self.sample_rate/1000) 16 | 17 | def clone(self): 18 | return self.__class__(self.data.clone(), self.sample_rate) 19 | 20 | def apply_tfms(self, tfms, **kwargs): 21 | x = self.clone() 22 | for tfm in tfms: 23 | x.data = tfm(x.data) 24 | return x 25 | 26 | @property 27 | def num_samples(self): 28 | return len(self.data) 29 | 30 | @property 31 | def duration(self): 32 | return self.num_samples / self.sample_rate 33 | 34 | def show(self, ax=None, figsize=(5, 1), player=True, title=None, **kwargs): 35 | if ax is None: 36 | _, ax = plt.subplots(figsize=figsize) 37 | if title: 38 | ax.set_title(title) 39 | timesteps = np.arange(len(self.data)) / self.sample_rate 40 | ax.plot(timesteps, self.data) 41 | ax.set_xlabel('Time (s)') 42 | plt.show() 43 | if player: 44 | # unable to display an IPython 'Audio' player in plt axes 45 | display(Audio(self.data, rate=self.sample_rate)) 46 | 47 | 48 | def open_audio(fn): 49 | sr, x = wavfile.read(fn) 50 | t = torch.from_numpy(x.astype(np.float32, copy=False)) 51 | if x.dtype == np.int16: 52 | t.div_(32767) 53 | elif x.dtype != np.float32: 54 | raise OSError('Encountered unexpected dtype: {}'.format(x.dtype)) 55 | return AudioClip(t, sr) 56 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .envrc 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # IntelliJ IDEA 108 | .idea/ 109 | *.iml 110 | 111 | # local data directory link 112 | notebooks/data 113 | 114 | # local notebook directory 115 | notebooks/local_nbs 116 | -------------------------------------------------------------------------------- /notebooks/[util] Pitch Shift Dataset.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from pathlib import Path\n", 10 | "from utils import pitch_shift_path" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "DATA = Path('data')" 20 | ] 21 | }, 22 | { 23 | "cell_type": "code", 24 | "execution_count": 3, 25 | "metadata": {}, 26 | "outputs": [ 27 | { 28 | "data": { 29 | "application/vnd.jupyter.widget-view+json": { 30 | "model_id": "67dda365395746acb7acfa5beec91d95", 31 | "version_major": 2, 32 | "version_minor": 0 33 | }, 34 | "text/plain": [ 35 | "HBox(children=(IntProgress(value=0, max=9473), HTML(value='')))" 36 | ] 37 | }, 38 | "metadata": {}, 39 | "output_type": "display_data" 40 | }, 41 | { 42 | "name": "stdout", 43 | "output_type": "stream", 44 | "text": [ 45 | "\n" 46 | ] 47 | } 48 | ], 49 | "source": [ 50 | "AUDIO_SRC = DATA/'freesound/audio_22050_trimmed/train'\n", 51 | "AUDIO_DST = DATA/'freesound/audio_22050_trimmed_tfms/train'\n", 52 | "\n", 53 | "pitch_shift_path(AUDIO_SRC, AUDIO_DST, max_steps=3, sample_rate=22050, n_tfms=5)" 54 | ] 55 | }, 56 | { 57 | "cell_type": "code", 58 | "execution_count": null, 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "# AUDIO_SRC = DATA/'freesound/audio_22050_trimmed/test'\n", 63 | "# AUDIO_DST = DATA/'freesound/audio_22050_trimmed_tfms/test'\n", 64 | "\n", 65 | "# pitch_shift_path(AUDIO_SRC, AUDIO_DST, max_steps=3, sample_rate=22050, n_tfms=5)" 66 | ] 67 | } 68 | ], 69 | "metadata": { 70 | "kernelspec": { 71 | "display_name": "Python 3", 72 | "language": "python", 73 | "name": "python3" 74 | }, 75 | "language_info": { 76 | "codemirror_mode": { 77 | "name": "ipython", 78 | "version": 3 79 | }, 80 | "file_extension": ".py", 81 | "mimetype": "text/x-python", 82 | "name": "python", 83 | "nbconvert_exporter": "python", 84 | "pygments_lexer": "ipython3", 85 | "version": "3.6.6" 86 | } 87 | }, 88 | "nbformat": 4, 89 | "nbformat_minor": 2 90 | } 91 | -------------------------------------------------------------------------------- /notebooks/[util] Convert to Mono.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 1, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "from pathlib import Path\n", 10 | "from utils import convert_to_mono" 11 | ] 12 | }, 13 | { 14 | "cell_type": "code", 15 | "execution_count": 2, 16 | "metadata": {}, 17 | "outputs": [], 18 | "source": [ 19 | "DATA = Path('data')\n", 20 | "\n", 21 | "AUDIOSET = DATA/'audioset'\n", 22 | "AUDIOSET_TRAIN = AUDIOSET/'train'\n", 23 | "AUDIOSET_VALID = AUDIOSET/'valid'\n", 24 | "\n", 25 | "AUDIOSET_MONO = DATA/'audioset_mono'\n", 26 | "AUDIOSET_MONO_TRAIN = AUDIOSET_MONO/'train'\n", 27 | "AUDIOSET_MONO_VALID = AUDIOSET_MONO/'valid'" 28 | ] 29 | }, 30 | { 31 | "cell_type": "code", 32 | "execution_count": 3, 33 | "metadata": {}, 34 | "outputs": [ 35 | { 36 | "data": { 37 | "application/vnd.jupyter.widget-view+json": { 38 | "model_id": "835da25033ae4f2c89707c565f1600f3", 39 | "version_major": 2, 40 | "version_minor": 0 41 | }, 42 | "text/plain": [ 43 | "HBox(children=(IntProgress(value=0, max=18725), HTML(value='')))" 44 | ] 45 | }, 46 | "metadata": {}, 47 | "output_type": "display_data" 48 | }, 49 | { 50 | "name": "stdout", 51 | "output_type": "stream", 52 | "text": [ 53 | "\n" 54 | ] 55 | } 56 | ], 57 | "source": [ 58 | "convert_to_mono(AUDIOSET_TRAIN, AUDIOSET_MONO_TRAIN)" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": 3, 64 | "metadata": {}, 65 | "outputs": [ 66 | { 67 | "data": { 68 | "application/vnd.jupyter.widget-view+json": { 69 | "model_id": "ed325ad377f1470d955c76f8ff176faf", 70 | "version_major": 2, 71 | "version_minor": 0 72 | }, 73 | "text/plain": [ 74 | "HBox(children=(IntProgress(value=0, max=17492), HTML(value='')))" 75 | ] 76 | }, 77 | "metadata": {}, 78 | "output_type": "display_data" 79 | }, 80 | { 81 | "name": "stdout", 82 | "output_type": "stream", 83 | "text": [ 84 | "\n" 85 | ] 86 | } 87 | ], 88 | "source": [ 89 | "convert_to_mono(AUDIOSET_VALID, AUDIOSET_MONO_VALID)" 90 | ] 91 | }, 92 | { 93 | "cell_type": "code", 94 | "execution_count": null, 95 | "metadata": {}, 96 | "outputs": [], 97 | "source": [] 98 | } 99 | ], 100 | "metadata": { 101 | "kernelspec": { 102 | "display_name": "Python 3", 103 | "language": "python", 104 | "name": "python3" 105 | }, 106 | "language_info": { 107 | "codemirror_mode": { 108 | "name": "ipython", 109 | "version": 3 110 | }, 111 | "file_extension": ".py", 112 | "mimetype": "text/x-python", 113 | "name": "python", 114 | "nbconvert_exporter": "python", 115 | "pygments_lexer": "ipython3", 116 | "version": "3.6.5" 117 | } 118 | }, 119 | "nbformat": 4, 120 | "nbformat_minor": 2 121 | } 122 | -------------------------------------------------------------------------------- /fastai_audio/transform.py: -------------------------------------------------------------------------------- 1 | import librosa as lr 2 | from fastai.torch_core import * 3 | 4 | __all__ = ['get_frequency_transforms', 'get_frequency_batch_transforms', 5 | 'FrequencyToMel', 'ToDecibels', 'Spectrogram'] 6 | 7 | 8 | def get_frequency_transforms(n_fft=2048, n_hop=512, window=torch.hann_window, 9 | n_mels=None, f_min=0, f_max=None, sample_rate=44100, 10 | decibels=True, ref='max', top_db=80.0, norm_db=True): 11 | tfms = [Spectrogram(n_fft=n_fft, n_hop=n_hop, window=window)] 12 | if n_mels is not None: 13 | tfms.append(FrequencyToMel(n_mels=n_mels, n_fft=n_fft, sr=sample_rate, 14 | f_min=f_min, f_max=f_max)) 15 | if decibels: 16 | tfms.append(ToDecibels(ref=ref, top_db=top_db, normalized=norm_db)) 17 | 18 | # only one list, as its applied to all dataloaders 19 | return tfms 20 | 21 | 22 | def get_frequency_batch_transforms(*args, add_channel_dim=True, **kwargs): 23 | tfms = get_frequency_transforms(*args, **kwargs) 24 | 25 | def _freq_batch_transformer(inputs): 26 | xs, ys = inputs 27 | for tfm in tfms: 28 | xs = tfm(xs) 29 | if add_channel_dim: 30 | xs.unsqueeze_(1) 31 | return xs, ys 32 | return [_freq_batch_transformer] 33 | 34 | 35 | class FrequencyToMel: 36 | def __init__(self, n_mels=40, n_fft=1024, sr=16000, 37 | f_min=0.0, f_max=None, device=None): 38 | mel_fb = lr.filters.mel(sr=sr, n_fft=n_fft, n_mels=n_mels, 39 | fmin=f_min, fmax=f_max).astype(np.float32) 40 | self.mel_filterbank = to_device(torch.from_numpy(mel_fb), device) 41 | 42 | def __call__(self, spec_f): 43 | spec_m = self.mel_filterbank @ spec_f 44 | return spec_m 45 | 46 | 47 | class ToDecibels: 48 | def __init__(self, 49 | power=2, # magnitude=1, power=2 50 | ref=1.0, 51 | top_db=None, 52 | normalized=True, 53 | amin=1e-7): 54 | self.constant = 10.0 if power == 2 else 20.0 55 | self.ref = ref 56 | self.top_db = abs(top_db) if top_db else top_db 57 | self.normalized = normalized 58 | self.amin = amin 59 | 60 | def __call__(self, x): 61 | batch_size = x.shape[0] 62 | if self.ref == 'max': 63 | ref_value = x.contiguous().view(batch_size, -1).max(dim=-1)[0] 64 | ref_value.unsqueeze_(1).unsqueeze_(1) 65 | else: 66 | ref_value = tensor(self.ref) 67 | spec_db = x.clamp_min(self.amin).log10_().mul_(self.constant) 68 | spec_db.sub_(ref_value.clamp_min_(self.amin).log10_().mul_(10.0)) 69 | if self.top_db is not None: 70 | max_spec = spec_db.view(batch_size, -1).max(dim=-1)[0] 71 | max_spec.unsqueeze_(1).unsqueeze_(1) 72 | spec_db = torch.max(spec_db, max_spec - self.top_db) 73 | if self.normalized: 74 | # normalize to [0, 1] 75 | spec_db.add_(self.top_db).div_(self.top_db) 76 | return spec_db 77 | 78 | 79 | # Returns power spectrogram (magnitude squared) 80 | class Spectrogram: 81 | def __init__(self, n_fft=1024, n_hop=256, window=torch.hann_window, 82 | device=None): 83 | self.n_fft = n_fft 84 | self.n_hop = n_hop 85 | self.window = to_device(window(n_fft), device) 86 | 87 | def __call__(self, x): 88 | X = torch.stft(x, 89 | n_fft=self.n_fft, 90 | hop_length=self.n_hop, 91 | win_length=self.n_fft, 92 | window=self.window, 93 | onesided=True, 94 | center=True, 95 | pad_mode='constant', 96 | normalized=True) 97 | # compute power from real and imag parts (magnitude^2) 98 | X.pow_(2.0) 99 | power = X[:,:,:,0] + X[:,:,:,1] 100 | return power 101 | -------------------------------------------------------------------------------- /tests/test_audio_transform.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from fastai.torch_core import to_np 4 | import librosa 5 | import numpy as np 6 | import pytest 7 | import torch 8 | 9 | from fastai_audio.audio_clip import open_audio 10 | from fastai_audio.transform import Spectrogram, ToDecibels, FrequencyToMel 11 | 12 | 13 | DATA_PATH = Path('tests/data') 14 | 15 | 16 | def get_data(): 17 | # load data from example files 18 | clips = [open_audio(fn) for fn in DATA_PATH.iterdir()] 19 | sample_rate = clips[0].sample_rate 20 | tensors = [clip.data for clip in clips] 21 | # make them all the same length so they can be combined into a batch 22 | min_len = min(t.size(0) for t in tensors) 23 | tensors = [t[:min_len] for t in tensors] 24 | batch_tensor = torch.stack(tensors) 25 | return batch_tensor, sample_rate 26 | 27 | 28 | def compute_np_batch(x_np, func, *args, **kwargs): 29 | return np.array([func(x, *args, **kwargs) for x in x_np]) 30 | 31 | 32 | def check_isclose(tensor, nparray, atol=1e-8): 33 | c = np.isclose(to_np(tensor), nparray, atol=atol) 34 | print(c.size, c.sum()) 35 | assert np.isclose(to_np(tensor), nparray, atol=atol).all() 36 | 37 | 38 | def stft(t, n_fft=1024, n_hop=256): 39 | return Spectrogram(n_fft=n_fft, n_hop=n_hop)(t) 40 | 41 | 42 | def power_to_db(t, ref=1.0, top_db=None, amin=1e-7): 43 | return ToDecibels(ref=ref, top_db=top_db, amin=amin, normalized=False)(t) 44 | 45 | 46 | def freq_to_mel(spec_t, n_mels=40, n_fft=1024, sr=16000, f_min=0.0, f_max=None): 47 | return FrequencyToMel(n_mels=n_mels, n_fft=n_fft, sr=sr, 48 | f_min=f_min, f_max=f_max)(spec_t) 49 | 50 | 51 | def ref_stft(x, n_fft, n_hop): 52 | spec = librosa.stft(x, n_fft=n_fft, hop_length=n_hop, center=True, 53 | win_length=n_fft, window='hann', pad_mode='constant') 54 | mag, phase = librosa.magphase(spec) 55 | power = mag ** 2.0 56 | # torch.stft(normalized=True) 57 | norm_power = power / n_fft 58 | return norm_power 59 | 60 | 61 | def ref_power_to_db(x, ref, top_db, amin): 62 | ref = np.max if ref == 'max' else ref 63 | return librosa.power_to_db(x, ref=ref, top_db=top_db, amin=amin) 64 | 65 | 66 | def ref_freq_to_mel(spec_np, n_mels, sr, n_fft, f_min, f_max): 67 | return librosa.feature.melspectrogram(S=spec_np, sr=sr, n_fft=n_fft, 68 | n_mels=n_mels, power=2.0, 69 | fmin=f_min, fmax=f_max) 70 | 71 | 72 | @pytest.mark.parametrize('n_fft', [512, 1024, 2048]) 73 | @pytest.mark.parametrize('n_hop', [128, 256, 512]) 74 | def test_stft(n_fft, n_hop): 75 | x_tensor, sr = get_data() 76 | x_np = to_np(x_tensor) 77 | 78 | stft_tensor = stft(x_tensor, n_fft=n_fft, n_hop=n_hop) 79 | stft_np = compute_np_batch(x_np, ref_stft, n_fft=n_fft, n_hop=n_hop) 80 | check_isclose(stft_tensor, stft_np) 81 | 82 | 83 | @pytest.mark.parametrize('ref', [1.0, 'max']) 84 | @pytest.mark.parametrize('top_db', [None, 40.0, 80.0, 100.0]) 85 | @pytest.mark.parametrize('amin', [1e-5, 1e-6, 1e-7]) 86 | def test_to_decibel(ref, top_db, amin): 87 | x_tensor, sr = get_data() 88 | stft_tensor = stft(x_tensor) 89 | stft_np = to_np(stft_tensor) 90 | 91 | db_tensor = power_to_db(stft_tensor, 92 | ref=ref, top_db=top_db, amin=amin) 93 | db_np = compute_np_batch(stft_np, ref_power_to_db, 94 | ref=ref, top_db=top_db, amin=amin) 95 | check_isclose(db_tensor, db_np, atol=amin*10) 96 | 97 | 98 | @pytest.mark.parametrize('n_fft', [512, 1024, 2048]) 99 | @pytest.mark.parametrize('n_mels', [40, 64, 128]) 100 | @pytest.mark.parametrize('f_min', [0.0, 20.0]) 101 | @pytest.mark.parametrize('f_max', [None, 8000.0]) 102 | def test_freq_to_mel(n_fft, n_mels, f_min, f_max): 103 | x_tensor, sr = get_data() 104 | stft_tensor = stft(x_tensor, n_fft=n_fft) 105 | stft_np = to_np(stft_tensor) 106 | 107 | mel_tensor = freq_to_mel(stft_tensor, n_mels=n_mels, n_fft=n_fft, sr=sr, 108 | f_min=f_min, f_max=f_max) 109 | mel_np = compute_np_batch(stft_np, ref_freq_to_mel, n_fft=n_fft, 110 | n_mels=n_mels, sr=sr, 111 | f_min=f_min, f_max=f_max) 112 | check_isclose(mel_tensor, mel_np) 113 | -------------------------------------------------------------------------------- /fastai_audio/data.py: -------------------------------------------------------------------------------- 1 | from fastai.basic_data import * 2 | from fastai.data_block import * 3 | from fastai.data_block import _maybe_squeeze 4 | from fastai.text import SortSampler, SortishSampler 5 | from fastai.torch_core import * 6 | from .audio_clip import * 7 | 8 | __all__ = ['pad_collate1d', 'pad_collate2d', 'AudioDataBunch', 'AudioItemList', ] 9 | 10 | 11 | def pad_collate1d(batch): 12 | xs, ys = zip(*to_data(batch)) 13 | max_len = max(x.size(0) for x in xs) 14 | padded_xs = torch.zeros(len(xs), max_len, dtype=xs[0].dtype) 15 | for i,x in enumerate(xs): 16 | padded_xs[i,:x.size(0)] = x 17 | return padded_xs, tensor(ys) 18 | 19 | 20 | # TODO: generalize this away from hard coding dim values 21 | def pad_collate2d(batch): 22 | xs, ys = zip(*to_data(batch)) 23 | max_len = max(max(x.size(1) for x in xs), 1) 24 | bins = xs[0].size(0) 25 | padded_xs = torch.zeros(len(xs), bins, max_len, dtype=xs[0].dtype) 26 | for i,x in enumerate(xs): 27 | padded_xs[i,:,:x.size(1)] = x 28 | return padded_xs, tensor(ys) 29 | 30 | 31 | class AudioDataBunch(DataBunch): 32 | @classmethod 33 | def create(cls, train_ds, valid_ds, test_ds=None, path='.', 34 | bs=64, equal_lengths=True, length_col=None, tfms=None, **kwargs): 35 | if equal_lengths: 36 | return super().create(train_ds, valid_ds, test_ds=test_ds, path=path, 37 | bs=bs, dl_tfms=tfms, **kwargs) 38 | else: 39 | datasets = super()._init_ds(train_ds, valid_ds, test_ds) 40 | train_ds, valid_ds, fix_ds = datasets[:3] 41 | if len(datasets) == 4: 42 | test_ds = datasets[3] 43 | 44 | train_lengths = train_ds.lengths(length_col) 45 | train_sampler = SortishSampler(train_ds.x, key=lambda i: train_lengths[i], bs=bs//2) 46 | train_dl = DataLoader(train_ds, batch_size=bs, sampler=train_sampler, **kwargs) 47 | 48 | # precalculate lengths ahead of time if they aren't included in xtra 49 | valid_lengths = valid_ds.lengths(length_col) 50 | valid_sampler = SortSampler(valid_ds.x, key=lambda i: valid_lengths[i]) 51 | valid_dl = DataLoader(valid_ds, batch_size=bs, sampler=valid_sampler, **kwargs) 52 | 53 | fix_lengths = fix_ds.lengths(length_col) 54 | fix_sampler = SortSampler(fix_ds.x, key=lambda i: fix_lengths[i]) 55 | fix_dl = DataLoader(fix_ds, batch_size=bs, sampler=fix_sampler, **kwargs) 56 | 57 | dataloaders = [train_dl, valid_dl, fix_dl] 58 | if test_ds is not None: 59 | test_dl = DataLoader(test_ds, batch_size=1, **kwargs) 60 | dataloaders.append(test_dl) 61 | 62 | return cls(*dataloaders, path=path, collate_fn=pad_collate1d, tfms=tfms) 63 | 64 | def show_batch(self, rows:int=5, ds_type:DatasetType=DatasetType.Train, **kwargs): 65 | dl = self.dl(ds_type) 66 | ds = dl.dl.dataset 67 | idx = np.random.choice(len(ds), size=rows, replace=False) 68 | batch = ds[idx] 69 | xs, ys = batch.x, batch.y 70 | self.train_ds.show_xys(xs, ys, **kwargs) 71 | 72 | 73 | class AudioItemList(ItemList): 74 | """NOTE: this class has been heavily adapted from ImageItemList""" 75 | _bunch = AudioDataBunch 76 | 77 | @classmethod 78 | def open(cls, fn): 79 | return open_audio(fn) 80 | 81 | def get(self, i): 82 | fn = super().get(i) 83 | return self.open(fn) 84 | 85 | def lengths(self, length_col=None): 86 | if length_col is not None and self.xtra is not None: 87 | lengths = self.xtra.iloc[:, df_names_to_idx(length_col, self.xtra)] 88 | lengths = _maybe_squeeze(lengths.values) 89 | else: 90 | lengths = [clip.num_samples for clip in self] 91 | return lengths 92 | 93 | @classmethod 94 | def from_df(cls, df, path, col=0, folder='.', suffix='', length_col=None): 95 | """Get the filenames in `col` of `df` and will had `path/folder` in front of them, 96 | `suffix` at the end. `create_func` is used to open the audio files.""" 97 | suffix = suffix or '' 98 | res = super().from_df(df, path=path, col=col, length_col=length_col) 99 | res.items = np.char.add(np.char.add(f'{folder}/', res.items.astype(str)), suffix) 100 | res.items = np.char.add(f'{res.path}/', res.items) 101 | return res 102 | 103 | def show_xys(self, xs, ys, figsize=None, **kwargs): 104 | for x, y in zip(xs, ys): 105 | x.show(title=y, **kwargs) 106 | 107 | -------------------------------------------------------------------------------- /notebooks/utils.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | from pathlib import Path 3 | from multiprocessing import Pool 4 | import os 5 | import shutil 6 | import numpy as np 7 | import pandas as pd 8 | import librosa 9 | from scipy.io import wavfile 10 | from tqdm import tqdm_notebook as tqdm 11 | import torch.nn.functional as F 12 | from fastai.basic_data import DatasetType 13 | 14 | 15 | def read_file(filename, path='', sample_rate=None, trim=False): 16 | ''' Reads in a wav file and returns it as an np.float32 array in the range [-1,1] ''' 17 | filename = Path(path) / filename 18 | file_sr, data = wavfile.read(filename) 19 | if data.dtype == np.int16: 20 | data = np.float32(data) / np.iinfo(np.int16).max 21 | elif data.dtype != np.float32: 22 | raise OSError('Encounted unexpected dtype: {}'.format(data.dtype)) 23 | if sample_rate is not None and sample_rate != file_sr: 24 | if len(data) > 0: 25 | data = librosa.core.resample(data, file_sr, sample_rate, res_type='kaiser_fast') 26 | file_sr = sample_rate 27 | if trim and len(data) > 1: 28 | data = librosa.effects.trim(data, top_db=40)[0] 29 | return data, file_sr 30 | 31 | 32 | def write_file(data, filename, path='', sample_rate=44100): 33 | ''' Writes a wav file to disk stored as int16 ''' 34 | filename = Path(path) / filename 35 | if data.dtype == np.int16: 36 | int_data = data 37 | elif data.dtype == np.float32: 38 | int_data = np.int16(data * np.iinfo(np.int16).max) 39 | else: 40 | raise OSError('Input datatype {} not supported, use np.float32'.format(data.dtype)) 41 | wavfile.write(filename, sample_rate, int_data) 42 | 43 | 44 | def load_audio_files(path, filenames=None, sample_rate=None, trim=False): 45 | ''' 46 | Loads in audio files and resamples if necessary. 47 | 48 | Args: 49 | path (str or PosixPath): directory where the audio files are located 50 | filenames (list of str): list of filenames to load. if not provided, load all 51 | files in path 52 | sampling_rate (int): if provided, audio will be resampled to this rate 53 | trim (bool): 54 | 55 | Returns: 56 | list of audio files as numpy arrays, dtype np.float32 between [-1, 1] 57 | ''' 58 | path = Path(path) 59 | if filenames is None: 60 | filenames = sorted(list(f.name for f in path.iterdir())) 61 | files = [] 62 | for filename in tqdm(filenames, unit='files'): 63 | data, file_sr = read_file(filename, path, sample_rate=sample_rate, trim=trim) 64 | files.append(data) 65 | return files 66 | 67 | 68 | def _resample(filename, src_path, dst_path, sample_rate=16000, trim=True): 69 | data, sr = read_file(filename, path=src_path, sample_rate=sample_rate, trim=trim) 70 | write_file(data, filename, path=dst_path, sample_rate=sample_rate) 71 | 72 | 73 | def resample_path(src_path, dst_path, **kwargs): 74 | transform_path(src_path, dst_path, _resample, **kwargs) 75 | 76 | 77 | def _to_mono(filename, dst_path): 78 | data, sr = read_file(filename) 79 | if len(data.shape) > 1: 80 | data = librosa.core.to_mono(data.T) # expects 2,n.. read_file returns n,2 81 | write_file(data, dst_path/filename.name, sample_rate=sr) 82 | 83 | 84 | def convert_to_mono(src_path, dst_path, processes=None): 85 | src_path, dst_path = Path(src_path), Path(dst_path) 86 | os.makedirs(dst_path, exist_ok=True) 87 | filenames = list(src_path.iterdir()) 88 | convert_fn = partial(_to_mono, dst_path=dst_path) 89 | with Pool(processes=processes) as pool: 90 | with tqdm(total=len(filenames), unit='files') as pbar: 91 | for _ in pool.imap_unordered(convert_fn, filenames): 92 | pbar.update() 93 | 94 | 95 | def transform_path(src_path, dst_path, transform_fn, fnames=None, processes=None, delete=False, **kwargs): 96 | src_path, dst_path = Path(src_path), Path(dst_path) 97 | if dst_path.exists() and delete: 98 | shutil.rmtree(dst_path) 99 | os.makedirs(dst_path, exist_ok=True) 100 | 101 | _transformer = partial(transform_fn, src_path=src_path, dst_path=dst_path, **kwargs) 102 | if fnames is None: 103 | fnames = [f.name for f in src_path.iterdir()] 104 | with Pool(processes=processes) as pool: 105 | with tqdm(total=len(fnames), unit='files') as pbar: 106 | for _ in pool.imap_unordered(_transformer, fnames): 107 | pbar.update() 108 | 109 | 110 | class RandomPitchShift(): 111 | def __init__(self, sample_rate=22050, max_steps=3): 112 | self.sample_rate = sample_rate 113 | self.max_steps = max_steps 114 | def __call__(self, x): 115 | n_steps = np.random.uniform(-self.max_steps, self.max_steps) 116 | x = librosa.effects.pitch_shift(x, sr=self.sample_rate, n_steps=n_steps) 117 | return x 118 | 119 | 120 | def _make_transforms(filename, src_path, dst_path, tfm_fn, sample_rate=22050, n_tfms=5): 121 | data, sr = read_file(filename, path=src_path) 122 | fn = Path(filename) 123 | # copy original file 124 | new_fn = fn.stem + '_00.wav' 125 | write_file(data, new_fn, path=dst_path, sample_rate=sample_rate) 126 | # make n_tfms modified files 127 | for i in range(n_tfms): 128 | new_fn = fn.stem + '_{:02d}'.format(i+1) + '.wav' 129 | if not (dst_path/new_fn).exists(): 130 | x = tfm_fn(data) 131 | write_file(x, new_fn, path=dst_path, sample_rate=sample_rate) 132 | 133 | 134 | def pitch_shift_path(src_path, dst_path, max_steps, sample_rate, n_tfms=5): 135 | pitch_shifter = RandomPitchShift(sample_rate=sample_rate, max_steps=max_steps) 136 | transform_path(src_path, dst_path, _make_transforms, 137 | tfm_fn=pitch_shifter, sample_rate=sample_rate, n_tfms=n_tfms) 138 | 139 | 140 | def rand_pad_crop(signal, pad_start_pct=0.1, crop_end_pct=0.5): 141 | r_pad, r_crop = np.random.rand(2) 142 | pad_start = int(pad_start_pct * r_pad * signal.shape[0]) 143 | crop_end = int(crop_end_pct * r_crop * signal.shape[0]) + 1 144 | return F.pad(signal[:-crop_end], (pad_start, 0), mode='constant') 145 | 146 | 147 | def get_transforms(min_len=2048): 148 | def _train_tfm(x): 149 | x = rand_pad_crop(x) 150 | if x.shape[0] < min_len: 151 | x = F.pad(x, (0, min_len - x.shape[0]), mode='constant') 152 | return x 153 | 154 | def _valid_tfm(x): 155 | if x.shape[0] < min_len: 156 | x = F.pad(x, (0, min_len - x.shape[0]), mode='constant') 157 | return x 158 | 159 | return [_train_tfm],[_valid_tfm] 160 | 161 | 162 | def save_submission(learn, filename, tta=False): 163 | fnames = [Path(f).name for f in learn.data.test_ds.x.items] 164 | get_predsfn = learn.TTA if tta else learn.get_preds 165 | preds = get_predsfn(ds_type=DatasetType.Test)[0] 166 | top_3 = np.array(learn.data.classes)[np.argsort(-preds, axis=1)[:, :3]] 167 | labels = [' '.join(list(x)) for x in top_3] 168 | df = pd.DataFrame({'fname': fnames, 'label': labels}) 169 | df.to_csv(filename, index=False) 170 | return df 171 | 172 | 173 | def precision(y_pred, y_true, thresh:float=0.2, eps:float=1e-9, sigmoid:bool=True): 174 | "Computes the f_beta between preds and targets" 175 | if sigmoid: y_pred = y_pred.sigmoid() 176 | y_pred = (y_pred>thresh).float() 177 | y_true = y_true.float() 178 | TP = (y_pred*y_true).sum(dim=1) 179 | prec = TP/(y_pred.sum(dim=1)+eps) 180 | return prec.mean() 181 | 182 | 183 | def recall(y_pred, y_true, thresh:float=0.2, eps:float=1e-9, sigmoid:bool=True): 184 | "Computes the f_beta between preds and targets" 185 | if sigmoid: y_pred = y_pred.sigmoid() 186 | y_pred = (y_pred>thresh).float() 187 | y_true = y_true.float() 188 | TP = (y_pred*y_true).sum(dim=1) 189 | rec = TP/(y_true.sum(dim=1)+eps) 190 | return rec.mean() 191 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | 179 | Copyright 2020 John Hartquist 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | --------------------------------------------------------------------------------