├── .github └── workflows │ ├── all.yml │ └── claude.yml ├── .gitignore ├── LICENSE ├── README.md ├── pyproject.toml ├── setup.cfg ├── src └── abletonparsing │ ├── __init__.py │ └── abletonparsing.py └── tests ├── assets ├── Incredible Bongo Band - Apache (loop off Live 12).wav.asd ├── Incredible Bongo Band - Apache (loop off).asd ├── Incredible Bongo Band - Apache (loop on Live 12).wav.asd ├── Incredible Bongo Band - Apache (loop on).asd ├── Incredible Bongo Band - Apache.wav └── README.md ├── output └── .gitignore └── test_basic.py /.github/workflows/all.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: [published] 12 | 13 | jobs: 14 | test: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, macos-latest] 20 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] # abletonparsing supports Python 3.8+ 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install system dependencies (Ubuntu) 32 | if: runner.os == 'Linux' 33 | run: | 34 | sudo apt-get install rubberband-cli 35 | 36 | - name: Install system dependencies (macOS) 37 | if: runner.os == 'macOS' 38 | run: | 39 | brew install rubberband 40 | 41 | - name: Install Python dependencies 42 | run: | 43 | python -m pip install --upgrade pip 44 | pip install -e ".[test]" 45 | 46 | - name: Run tests 47 | run: | 48 | python -m pytest tests 49 | 50 | lint: 51 | runs-on: ubuntu-latest 52 | needs: test 53 | steps: 54 | - name: Checkout code 55 | uses: actions/checkout@v4 56 | 57 | - name: Set up Python 58 | uses: actions/setup-python@v5 59 | with: 60 | python-version: "3.11" 61 | 62 | - name: Install dependencies 63 | run: | 64 | python -m pip install --upgrade pip 65 | pip install flake8 66 | 67 | - name: Lint with flake8 68 | run: | 69 | # stop the build if there are Python syntax errors or undefined names 70 | flake8 src --count --select=E9,F63,F7,F82 --show-source --statistics 71 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 72 | flake8 src --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 73 | 74 | publish: 75 | needs: [test, lint] 76 | runs-on: ubuntu-latest 77 | if: github.event_name == 'release' && github.event.action == 'published' 78 | 79 | steps: 80 | - name: Checkout code 81 | uses: actions/checkout@v4 82 | 83 | - name: Set up Python 84 | uses: actions/setup-python@v5 85 | with: 86 | python-version: "3.11" 87 | 88 | - name: Install build dependencies 89 | run: | 90 | python -m pip install --upgrade pip 91 | pip install build 92 | 93 | - name: Build package 94 | run: | 95 | python -m build 96 | 97 | - name: Publish package 98 | uses: pypa/gh-action-pypi-publish@release/v1 99 | with: 100 | password: ${{ secrets.PYPI_API_TOKEN }} 101 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Braun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AbletonParsing 2 | 3 | [![CI](https://github.com/DBraun/AbletonParsing/actions/workflows/all.yml/badge.svg)](https://github.com/DBraun/AbletonParsing/actions/workflows/all.yml) 4 | [![PyPI version](https://badge.fury.io/py/abletonparsing.svg)](https://badge.fury.io/py/abletonparsing) 5 | 6 | Parse an Ableton ASD clip file and its warp markers in Python. This module has been tested with `.asd` files saved with Ableton 9 and Ableton 10. 7 | 8 | **Note:** Ableton Live 12 uses a different binary format that is not yet supported by this library. 9 | 10 | ## Install 11 | 12 | `pip install abletonparsing` 13 | 14 | ### Development Dependencies 15 | 16 | To run the tests, you'll need to install additional dependencies: 17 | 18 | ```bash 19 | # On macOS 20 | brew install rubberband 21 | 22 | # On Ubuntu/Debian 23 | sudo apt-get install -y rubberband-cli 24 | 25 | # Install Python test dependencies 26 | pip install abletonparsing[test] 27 | # or 28 | pip install -e ".[test]" # for development 29 | ``` 30 | 31 | ## API 32 | 33 | Clip class: 34 | * .loop_on - ( bool , READ/WRITE ) - Loop toggle is on 35 | * .start_marker - ( float , READ/WRITE ) - Start marker in beats relative to 1.1.1 36 | * .end_marker - ( float , READ/WRITE ) - End marker in beats relative to 1.1.1 37 | * .loop_start - ( float , READ/WRITE ) - Loop start in beats relative to 1.1.1 38 | * .loop_end - ( float , READ/WRITE ) - Loop end in beats relative to 1.1.1 39 | * .hidden_loop_start - ( float , READ/WRITE ) - Hidden loop start in beats relative to 1.1.1 40 | * .hidden_loop_end - ( float , READ/WRITE ) - Hidden loop end in beats relative to 1.1.1 41 | * .warp_markers - ( list[WarpMarker] , READ/WRITE ) - List of warp markers 42 | * .warp_on - ( bool , READ/WRITE ) - Warping is on 43 | * .sr - ( float , READ/WRITE ) - Sample rate of audio data 44 | 45 | WarpMarker class: 46 | * .seconds - ( float , READ/WRITE ) - Position in seconds in the audio data. 47 | * .beats - ( float , READ/WRITE ) - Position in "beats" (typically quarter note) relative to 1.1.1 48 | 49 | If `loop_on` is false, then `loop_start` will equal the `start_marker`, and `loop_end` will equal the `end_marker`. 50 | 51 | ## Example 52 | 53 | ```python 54 | import abletonparsing 55 | 56 | import librosa 57 | import soundfile as sf 58 | import pyrubberband as pyrb 59 | 60 | bpm = 130. 61 | audio_path = 'drums.wav' 62 | clip_path = audio_path + '.asd' 63 | 64 | audio_data, sr = librosa.load(audio_path, sr=None, mono=False) 65 | num_samples = audio_data.shape[1] 66 | 67 | clip = abletonparsing.Clip(clip_path, sr, num_samples) 68 | 69 | time_map = clip.get_time_map(bpm) 70 | 71 | # Time-stretch the audio to the requested bpm. 72 | output_audio = pyrb.timemap_stretch(audio_data.transpose(), sr, time_map) 73 | 74 | with sf.SoundFile('output.wav', 'w', sr, 2, 'PCM_24') as f: 75 | f.write(output_audio) 76 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = abletonparsing 3 | version = attr: abletonparsing.__version__ 4 | author = David Braun 5 | author_email = braun@ccrma.stanford.edu 6 | description = Python module for parsing Ableton Live ASD clip files containing warp markers. 7 | long_description = file: README.md 8 | long_description_content_type = text/markdown 9 | url = https://github.com/DBraun/AbletonParsing 10 | project_urls = 11 | Bug Tracker = https://github.com/DBraun/AbletonParsing/issues 12 | classifiers = 13 | Programming Language :: Python :: 3 14 | License :: OSI Approved :: MIT License 15 | Operating System :: OS Independent 16 | 17 | [options] 18 | package_dir = 19 | = src 20 | packages = find: 21 | python_requires = >=3.8 22 | 23 | [options.packages.find] 24 | where = src 25 | 26 | [options.extras_require] 27 | test = 28 | pytest 29 | librosa 30 | soundfile 31 | pyrubberband 32 | -------------------------------------------------------------------------------- /src/abletonparsing/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | from .abletonparsing import * 4 | 5 | __version__ = '0.1.3' 6 | -------------------------------------------------------------------------------- /src/abletonparsing/abletonparsing.py: -------------------------------------------------------------------------------- 1 | from os.path import isfile 2 | from struct import unpack 3 | from typing import List 4 | 5 | 6 | class WarpMarker: 7 | 8 | def __init__(self, seconds : float, beats : float): 9 | 10 | self.seconds = seconds 11 | self.beats = beats 12 | 13 | def __repr__(self): 14 | return "WarpMarker(seconds={0},beats={1})".format(self.seconds, self.beats) 15 | 16 | 17 | class Clip: 18 | 19 | """An unofficial representation of an Ableton Live Clip.""" 20 | 21 | @property 22 | def loop_on(self): 23 | return self._loop_on 24 | 25 | @loop_on.setter 26 | def loop_on(self, value: bool): 27 | self._loop_on = value 28 | 29 | @property 30 | def start_marker(self): 31 | return self._start_marker 32 | 33 | @start_marker.setter 34 | def start_marker(self, value): 35 | self._start_marker = value 36 | 37 | @property 38 | def end_marker(self): 39 | return self._end_marker 40 | 41 | @end_marker.setter 42 | def end_marker(self, value): 43 | self._end_marker = value 44 | 45 | @property 46 | def loop_start(self): 47 | return self._loop_start 48 | 49 | @loop_start.setter 50 | def loop_start(self, value): 51 | self._loop_start = value 52 | 53 | @property 54 | def loop_end(self): 55 | return self._loop_end 56 | 57 | @loop_end.setter 58 | def loop_end(self, value): 59 | self._loop_end = value 60 | 61 | @property 62 | def hidden_loop_start(self): 63 | return self._hidden_loop_start 64 | 65 | @hidden_loop_start.setter 66 | def hidden_loop_start(self, value): 67 | self._hidden_loop_start = value 68 | 69 | @property 70 | def hidden_loop_end(self): 71 | return self._hidden_loop_end 72 | 73 | @hidden_loop_end.setter 74 | def hidden_loop_end(self, value): 75 | self._hidden_loop_end = value 76 | 77 | @property 78 | def warp_markers(self): 79 | return self._warp_markers 80 | 81 | @warp_markers.setter 82 | def warp_markers(self, warp_markers : List[WarpMarker]): 83 | self._warp_markers = warp_markers 84 | 85 | @property 86 | def warp_on(self): 87 | return self._warp_on 88 | 89 | @warp_on.setter 90 | def warp_on(self, warp_on: bool): 91 | self._warp_on = warp_on 92 | 93 | @property 94 | def sr(self): 95 | return self._sr 96 | 97 | @sr.setter 98 | def sr(self, value : int): 99 | self._sr = value 100 | 101 | 102 | def __init__(self, clip_path: str, sr: int, num_samples: int): 103 | 104 | ''' 105 | Parameters 106 | ---------- 107 | clip_path : str 108 | Path to an Ableton ASD file. 109 | sr : int 110 | Sample rate the of the audio file associated with the ASD clip. 111 | num_samples : int 112 | Number of audio samples per channel in the audio file associated with the ASD clip. 113 | ''' 114 | 115 | self._loop_on = False 116 | self._start_marker = 0. 117 | self._end_marker = 0. 118 | self._loop_start = 0. 119 | self._loop_end = 0. 120 | self._hidden_loop_start = 0. 121 | self._hidden_loop_end = 0. 122 | self._warp_markers = [] 123 | self._warp_on = False 124 | self._sr = sr 125 | self._num_samples = num_samples 126 | 127 | self._parse_asd_file(clip_path) 128 | 129 | 130 | def get_time_map(self, bpm : float): 131 | 132 | '''Parse an Ableton `asd` file into a time map to be used with Rubberband library's `timemap_stretch`. 133 | 134 | Parameters 135 | ---------- 136 | bpm : float > 0 137 | The beats-per-minute of the time map which will be returned 138 | 139 | Returns 140 | ------- 141 | time_map : list 142 | Each element is a tuple `t` of length 2 which corresponds to the 143 | source sample position and target sample position. 144 | 145 | If `t[1] < t[0]` the track will be sped up in this area. 146 | 147 | Refer to the function `timemap_stretch`. 148 | ''' 149 | 150 | time_map = [] 151 | for wm in self._warp_markers: 152 | 153 | sample_index = int(wm.seconds*self._sr) 154 | 155 | if sample_index <= self._num_samples: 156 | time_map.append([sample_index, int(wm.beats*(60./bpm)*self._sr)]) 157 | else: 158 | return time_map 159 | 160 | wm1 = self._warp_markers[-2] # second to last warp marker 161 | wm2 = self._warp_markers[-1] # last warp marker 162 | 163 | # The difference in beats divided by the difference in seconds, times 60 seconds = BPM. 164 | last_bpm = (wm2.beats - wm1.beats) / (wm2.seconds - wm1.seconds) * 60. 165 | 166 | # Extrapolate the last bpm 167 | mapped_last_sample = int(time_map[-1][1] + (self._num_samples-time_map[-1][0])*last_bpm/bpm) 168 | 169 | time_map.append([self._num_samples, mapped_last_sample]) 170 | 171 | return time_map 172 | 173 | 174 | def _parse_asd_file(self, filepath : str): 175 | 176 | '''Parse an Ableton `asd` file. 177 | 178 | Parameters 179 | ---------- 180 | filepath : str 181 | Path to an Ableton clip file with ".asd" extension 182 | ''' 183 | 184 | if not isfile(filepath): 185 | raise FileNotFoundError(f"No such file: '{filepath}'") 186 | 187 | f = open(filepath, 'rb') 188 | asd_bin = f.read() 189 | f.close() 190 | 191 | index = asd_bin.find(b'SampleOverViewLevel') 192 | if index > 0: 193 | # Assume the clip file was saved with Ableton Live 10. 194 | # Find the second appearance of SampleOverViewLevel 195 | index = asd_bin.find(b'SampleOverViewLevel', index+1) 196 | # Go forward a fixed number of bytes. 197 | index += 90 198 | else: 199 | # Assume the clip file was saved with Ableton Live 9. 200 | index = asd_bin.find(b'SampleData') 201 | # Find the second appearance of SampleData 202 | index = asd_bin.find(b'SampleData', index+1) 203 | # Go forward a fixed number of bytes. 204 | index += 2712 205 | 206 | def read_double(buffer, index): 207 | size_double = 8 # a double is 8 bytes 208 | return unpack('d', buffer[index:index+size_double])[0], index+size_double 209 | 210 | def read_bool(buffer, index): 211 | size_bool = 1 212 | return unpack('?', buffer[index:index+size_bool])[0], index+size_bool 213 | 214 | self._loop_start, index = read_double(asd_bin, index) 215 | self._loop_end, index = read_double(asd_bin, index) 216 | sample_offset, index = read_double(asd_bin, index) 217 | self._hidden_loop_start, index = read_double(asd_bin, index) 218 | self._hidden_loop_end, index = read_double(asd_bin, index) 219 | self._end_marker, index = read_double(asd_bin, index) 220 | index += 3 221 | self._warp_on, index = read_bool(asd_bin, index) 222 | 223 | self._start_marker = self._loop_start + sample_offset 224 | 225 | self._warp_markers = [] 226 | index = asd_bin.find(b'WarpMarker') 227 | last_good_index = -1 228 | while True: 229 | 230 | index = asd_bin.find(b'WarpMarker', index+1) 231 | if index < 0: 232 | index = last_good_index 233 | break 234 | 235 | index += 14 # WarpMarker is 10 bytes. Then add 4. 236 | 237 | marker_seconds, index = read_double(asd_bin, index) 238 | marker_beats, index = read_double(asd_bin, index) 239 | 240 | self._warp_markers.append(WarpMarker(marker_seconds, marker_beats)) 241 | 242 | last_good_index = index 243 | 244 | index += 7 245 | # The loop_on value can be found some bytes after the last warp marker. 246 | self._loop_on, index = read_bool(asd_bin, index) 247 | -------------------------------------------------------------------------------- /tests/assets/Incredible Bongo Band - Apache (loop off Live 12).wav.asd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBraun/AbletonParsing/26e8d4b43d13f6558f628f817e1eb48948600f1d/tests/assets/Incredible Bongo Band - Apache (loop off Live 12).wav.asd -------------------------------------------------------------------------------- /tests/assets/Incredible Bongo Band - Apache (loop off).asd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBraun/AbletonParsing/26e8d4b43d13f6558f628f817e1eb48948600f1d/tests/assets/Incredible Bongo Band - Apache (loop off).asd -------------------------------------------------------------------------------- /tests/assets/Incredible Bongo Band - Apache (loop on Live 12).wav.asd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBraun/AbletonParsing/26e8d4b43d13f6558f628f817e1eb48948600f1d/tests/assets/Incredible Bongo Band - Apache (loop on Live 12).wav.asd -------------------------------------------------------------------------------- /tests/assets/Incredible Bongo Band - Apache (loop on).asd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBraun/AbletonParsing/26e8d4b43d13f6558f628f817e1eb48948600f1d/tests/assets/Incredible Bongo Band - Apache (loop on).asd -------------------------------------------------------------------------------- /tests/assets/Incredible Bongo Band - Apache.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DBraun/AbletonParsing/26e8d4b43d13f6558f628f817e1eb48948600f1d/tests/assets/Incredible Bongo Band - Apache.wav -------------------------------------------------------------------------------- /tests/assets/README.md: -------------------------------------------------------------------------------- 1 | # Licenses for assets 2 | * [Incredible Bongo Band - Apache.wav](https://freesound.org/people/Bronxio/sounds/242969/) [CC0 1.0 Universal (CC0 1.0) 3 | Public Domain Dedication](https://creativecommons.org/publicdomain/zero/1.0/) 4 | -------------------------------------------------------------------------------- /tests/output/.gitignore: -------------------------------------------------------------------------------- 1 | *.wav -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import abletonparsing 2 | import pytest 3 | from pathlib import Path 4 | 5 | import librosa 6 | import soundfile as sf 7 | import pyrubberband as pyrb 8 | 9 | # Get the directory where this test file is located 10 | TEST_DIR = Path(__file__).parent 11 | ASSETS_DIR = TEST_DIR / "assets" 12 | OUTPUT_DIR = TEST_DIR / "output" 13 | 14 | # Ensure output directory exists 15 | OUTPUT_DIR.mkdir(exist_ok=True) 16 | 17 | def _test_basic_params(audio_path, clip_path, loop_on, output_path, bpm, 18 | start_marker=0, end_marker=5, hidden_loop_start=4, hidden_loop_end=6, 19 | loop_start=4, loop_end=6, sr=44100, warp_on=True): 20 | 21 | audio_data, sr = librosa.load(audio_path, sr=None, mono=False) 22 | num_samples = audio_data.shape[1] 23 | 24 | clip = abletonparsing.Clip(clip_path, sr, num_samples) 25 | print(clip.warp_markers) 26 | 27 | assert(clip.loop_on == loop_on) 28 | assert(clip.warp_on == warp_on) 29 | 30 | assert(clip.sr == sr) 31 | assert(clip.start_marker == start_marker) 32 | assert(clip.end_marker == end_marker) 33 | 34 | if clip.loop_on: 35 | assert(clip.hidden_loop_start == hidden_loop_start) 36 | assert(clip.hidden_loop_end == hidden_loop_end) 37 | assert(clip.loop_start == loop_start) 38 | assert(clip.loop_end == loop_end) 39 | else: 40 | assert(clip.hidden_loop_start == hidden_loop_start) 41 | assert(clip.hidden_loop_end == hidden_loop_end) 42 | assert(clip.loop_start == loop_start) 43 | assert(clip.loop_end == loop_end) 44 | 45 | time_map = clip.get_time_map(bpm) 46 | print('time_map: ', time_map) 47 | 48 | # Time-stretch the audio to the requested bpm. 49 | output_audio = pyrb.timemap_stretch(audio_data.transpose(), sr, time_map) 50 | 51 | with sf.SoundFile(output_path, 'w', sr, 2, 'PCM_24') as f: 52 | f.write(output_audio) 53 | 54 | def test_basic1(): 55 | audio_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache.wav') 56 | clip_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache (loop on).asd') 57 | loop_on = True 58 | output_path = str(OUTPUT_DIR / 'test_basic1.wav') 59 | _test_basic_params(audio_path, clip_path, loop_on, output_path, 140, 60 | start_marker=0, end_marker=5, hidden_loop_start=4, hidden_loop_end=6, 61 | loop_start=4, loop_end=6, sr=44100, warp_on=True) 62 | 63 | def test_basic2(): 64 | audio_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache.wav') 65 | clip_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache (loop off).asd') 66 | loop_on = False 67 | output_path = str(OUTPUT_DIR / 'test_basic2.wav') 68 | _test_basic_params(audio_path, clip_path, loop_on, output_path, 140, 69 | start_marker=0, end_marker=5, hidden_loop_start=4, hidden_loop_end=6, 70 | loop_start=0, loop_end=5, sr=44100, warp_on=True) 71 | 72 | @pytest.mark.skip(reason="Live 12 format not yet supported - returns garbage values for clip parameters") 73 | def test_live12_loop_on(): 74 | audio_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache.wav') 75 | clip_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache (loop on Live 12).wav.asd') 76 | loop_on = True 77 | output_path = str(OUTPUT_DIR / 'test_live12_loop_on.wav') 78 | _test_basic_params(audio_path, clip_path, loop_on, output_path, 140, 79 | start_marker=0, end_marker=5, hidden_loop_start=4, hidden_loop_end=6, 80 | loop_start=4, loop_end=6, sr=44100, warp_on=True) 81 | 82 | @pytest.mark.skip(reason="Live 12 format not yet supported - returns garbage values for clip parameters") 83 | def test_live12_loop_off(): 84 | audio_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache.wav') 85 | clip_path = str(ASSETS_DIR / 'Incredible Bongo Band - Apache (loop off Live 12).wav.asd') 86 | loop_on = False 87 | output_path = str(OUTPUT_DIR / 'test_live12_loop_off.wav') 88 | _test_basic_params(audio_path, clip_path, loop_on, output_path, 140, 89 | start_marker=0, end_marker=5, hidden_loop_start=4, hidden_loop_end=6, 90 | loop_start=0, loop_end=5, sr=44100, warp_on=True) 91 | 92 | # def test_basic3(): 93 | # # todo: include public domain audio and ableton 9 asd clip in test. 94 | # # audio_path = '' 95 | # # clip_path = '' 96 | # loop_on = True 97 | # output_path = 'output/test_basic3.wav' 98 | # _test_basic_params(audio_path, clip_path, loop_on, output_path, 140, 99 | # start_marker=0, end_marker=265.6771717865468, hidden_loop_start=0, hidden_loop_end=16, 100 | # loop_start=-0.046875, loop_end=15.953125, sr=44100, warp_on=True) 101 | --------------------------------------------------------------------------------