Repository: demberto/PyFLP Branch: master Commit: f937126b888c Files: 177 Total size: 395.7 KB Directory structure: gitextract_89mlfebd/ ├── .all-contributorsrc ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── dependabot.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .vscode/ │ ├── extensions.json │ ├── settings.json │ └── tasks.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs/ │ ├── Makefile │ ├── architecture/ │ │ ├── flp-format.rst │ │ ├── how-it-works.rst │ │ └── reference.rst │ ├── architecture.rst │ ├── changelog.rst │ ├── conf.py │ ├── contributing.rst │ ├── faq.rst │ ├── features.rst │ ├── guides/ │ │ ├── plugin.rst │ │ └── reversing.rst │ ├── guides.rst │ ├── handbook.rst │ ├── helping.rst │ ├── index.rst │ ├── limitations.rst │ ├── make.bat │ ├── reference/ │ │ ├── arrangement/ │ │ │ ├── arrangement.rst │ │ │ ├── index.rst │ │ │ ├── playlist.rst │ │ │ └── track.rst │ │ ├── channel/ │ │ │ ├── automation.rst │ │ │ ├── channel.rst │ │ │ ├── display-group.rst │ │ │ ├── index.rst │ │ │ ├── instrument.rst │ │ │ ├── layer.rst │ │ │ ├── sampler.rst │ │ │ └── shared.rst │ │ ├── controllers.rst │ │ ├── events.rst │ │ ├── exceptions.rst │ │ ├── mixer/ │ │ │ ├── index.rst │ │ │ ├── insert.rst │ │ │ └── slot.rst │ │ ├── patterns/ │ │ │ ├── index.rst │ │ │ └── pattern.rst │ │ ├── plugins/ │ │ │ ├── effects.rst │ │ │ ├── generators.rst │ │ │ ├── index.rst │ │ │ └── vst.rst │ │ ├── project.rst │ │ └── timemarkers.rst │ ├── reference.rst │ └── requirements.txt ├── pyflp/ │ ├── __init__.py │ ├── _adapters.py │ ├── _descriptors.py │ ├── _events.py │ ├── _models.py │ ├── arrangement.py │ ├── channel.py │ ├── controller.py │ ├── exceptions.py │ ├── mixer.py │ ├── pattern.py │ ├── plugin.py │ ├── project.py │ ├── py.typed │ ├── timemarker.py │ └── types.py ├── pyproject.toml ├── requirements.txt ├── tests/ │ ├── __init__.py │ ├── assets/ │ │ ├── FL 20.8.4.flp │ │ ├── channels/ │ │ │ ├── +4800-cents.fst │ │ │ ├── -4800-cents.fst │ │ │ ├── 100%-left.fst │ │ │ ├── 100%-right.fst │ │ │ ├── arp.fst │ │ │ ├── automation-lfo.fst │ │ │ ├── automation-points.fst │ │ │ ├── colored.fst │ │ │ ├── cut-groups.fst │ │ │ ├── delay.fst │ │ │ ├── disabled.fst │ │ │ ├── envelope.fst │ │ │ ├── full-volume.fst │ │ │ ├── iconified.fst │ │ │ ├── keyboard.fst │ │ │ ├── layer-crossfade.fst │ │ │ ├── layer-random.fst │ │ │ ├── level-adjusts.fst │ │ │ ├── lfo.fst │ │ │ ├── locked.fst │ │ │ ├── polyphony.fst │ │ │ ├── routed.fst │ │ │ ├── sampler-content.fst │ │ │ ├── sampler-filter.fst │ │ │ ├── sampler-fx.fst │ │ │ ├── sampler-path.fst │ │ │ ├── sampler-playback.fst │ │ │ ├── sampler-stretching.fst │ │ │ ├── time.fst │ │ │ ├── tracking.fst │ │ │ └── zero-volume.fst │ │ ├── corrupted/ │ │ │ ├── invalid-data-magic.flp │ │ │ ├── invalid-event-size.flp │ │ │ ├── invalid-format.flp │ │ │ ├── invalid-header-magic.flp │ │ │ ├── invalid-header-size.flp │ │ │ └── invalid-ppq.flp │ │ ├── inserts/ │ │ │ ├── 100%-left.fst │ │ │ ├── 100%-merged.fst │ │ │ ├── 100%-right.fst │ │ │ ├── 100%-separated.fst │ │ │ ├── 50ms-input-latency.fst │ │ │ ├── 50ms-track-latency.fst │ │ │ ├── armed.fst │ │ │ ├── channels-swapped.fst │ │ │ ├── colored.fst │ │ │ ├── disabled.fst │ │ │ ├── effects-bypassed.fst │ │ │ ├── iconified.fst │ │ │ ├── locked.fst │ │ │ ├── polarity-reversed.fst │ │ │ ├── post-eq.fst │ │ │ ├── separator.fst │ │ │ └── zero-volume.fst │ │ ├── patterns/ │ │ │ ├── c-major-scale.fsc │ │ │ ├── c5-1bar.fsc │ │ │ ├── color-9.fsc │ │ │ ├── common-group.fsc │ │ │ ├── empty.fsc │ │ │ ├── fine-pitch-min-max.fsc │ │ │ ├── modx-min-max.fsc │ │ │ ├── mody-min-max.fsc │ │ │ ├── multi-channel.flp │ │ │ ├── pan-min-max.fsc │ │ │ ├── release-min-max.fsc │ │ │ ├── slide-note.fsc │ │ │ └── velocity-min-max.fsc │ │ └── plugins/ │ │ ├── boobass.fst │ │ ├── fruit-kick.fst │ │ ├── fruity-balance.fst │ │ ├── fruity-blood-overdrive.fst │ │ ├── fruity-center.fst │ │ ├── fruity-fast-dist.fst │ │ ├── fruity-send.fst │ │ ├── fruity-soft-clipper.fst │ │ ├── fruity-stereo-enhancer.fst │ │ ├── fruity-wrapper.fst │ │ ├── plucked.fst │ │ ├── soundgoodizer.fst │ │ └── xfer-djmfilter.fst │ ├── conftest.py │ ├── test_arrangement.py │ ├── test_channel.py │ ├── test_corrupted.py │ ├── test_events.py │ ├── test_mixer.py │ ├── test_models.py │ ├── test_pattern.py │ ├── test_plugin.py │ └── test_project.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .all-contributorsrc ================================================ { "files": [ "README.md" ], "imageSize": 50, "commit": false, "contributors": [ { "login": "nickberry17", "name": "nickberry17", "avatar_url": "https://avatars.githubusercontent.com/u/18670565?v=4", "profile": "https://github.com/nickberry17", "contributions": [ "code" ] }, { "login": "zacanger", "name": "zacanger", "avatar_url": "https://avatars.githubusercontent.com/u/12520493?v=4", "profile": "https://github.com/zacanger", "contributions": [ "bug", "doc" ] }, { "login": "ttaschke", "name": "Tim", "avatar_url": "https://avatars.githubusercontent.com/u/7067750?v=4", "profile": "https://github.com/ttaschke", "contributions": [ "doc", "code", "maintenance" ] } ], "contributorsPerLine": 7, "projectName": "PyFLP", "projectOwner": "demberto", "repoType": "github", "repoHost": "https://github.com", "skipCi": true } ================================================ FILE: .editorconfig ================================================ # EditorConfig is awesome: https://EditorConfig.org # top-most EditorConfig file root = true [*] charset = utf-8 insert_final_newline = true indent_size = 2 indent_style = space trim_trailing_whitespace = true # 4 space indentation [*.{cfg,py}] indent_size = 4 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: File a bug report title: "🐞 " labels: ["bug"] body: - type: textarea id: description attributes: label: Describe the issue description: A clear and a concise description of what happened. validations: required: true - type: input id: version attributes: label: What version of PyFLP are you using? validations: required: true - type: textarea id: code attributes: label: What code caused this issue? render: python3 validations: required: true - type: textarea id: additional attributes: label: Screenshots, Additional info - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow PyFLP's [Code of Conduct](https://github.com/demberto/PyFLP/blob/master/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ contact_links: - name: Discussions url: https://github.com/demberto/PyFLP/discussions about: Please ask and answer questions here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: ✨ I want a new feature title: "✨ " labels: ["enhancement"] body: - type: textarea id: description attributes: label: Describe the feature validations: required: true - type: input id: version attributes: label: What version of PyFLP are you using? validations: required: true - type: textarea id: additional attributes: label: Screenshots, Additional info - type: checkboxes id: terms attributes: label: Code of Conduct description: By submitting this issue, you agree to follow PyFLP's [Code of Conduct](https://github.com/demberto/PyFLP/blob/master/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: pip directory: / # Location of package manifests schedule: interval: weekly assignees: - demberto ignore: - dependency-name: m2r2 ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # name: "CodeQL" on: push: branches: [ "master" ] pull_request: # The branches below must be a subset of the branches above branches: [ "master" ] schedule: - cron: '36 19 * * 2' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v2 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/publish.yml ================================================ name: publish on: push: tags: - v* workflow_dispatch: jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v3 with: python-version: "3.x" - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: Build package run: python -m build - name: Twine check run: twine check dist/* - name: Get changelog for release id: changelog uses: mindsers/changelog-reader-action@v2 - name: Create release uses: ncipollo/release-action@v1 with: artifacts: "dist/*" body: ${{ steps.changelog.outputs.changes }} - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: push: branches: - master pull_request: branches: - master workflow_dispatch: jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "pypy3.8", "pypy3.9"] os: ["macos-latest", "windows-latest", "ubuntu-latest"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} cache: "pip" - name: Install dependencies run: | python -m pip install -U pip pip install tox tox-gh - name: Test with tox run: tox - name: Upload coverage artifacts uses: actions/upload-artifact@v3 with: name: coverage-artifacts path: ./.coverage.* upload-to-codecov: needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python 3.10 uses: actions/setup-python@v4 with: python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip pip install coverage[toml] - name: Download artifacts uses: actions/download-artifact@v3 with: name: coverage-artifacts - name: Coverage data preparation for shitty codecov run: coverage combine - name: Upload to Codecov uses: codecov/codecov-action@v3 ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # setuptools_scm pyflp/_version.py # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ docs/_images/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # Ruff .ruff_cache/ # Just easier than running tests main.py ================================================ FILE: .pre-commit-config.yaml ================================================ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-toml - id: check-yaml - id: check-added-large-files - id: requirements-txt-fixer - id: check-vcs-permalinks - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.0.277 hooks: - id: ruff - repo: https://github.com/asottile/pyupgrade rev: v3.9.0 hooks: - id: pyupgrade args: ["--py38-plus"] ================================================ FILE: .readthedocs.yaml ================================================ # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 tools: python: "3" # Build documentation in this directory with Sphinx sphinx: configuration: docs/conf.py # Optionally declare the Python requirements required to build your docs python: install: - path: . # Required by importlib.version for Sphinx (setuptools_scm) - requirements: docs/requirements.txt - requirements: requirements.txt ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "aaron-bond.better-comments", // Highlighting annotated comments "bierner.markdown-preview-github-styles", // Github-style markdown preview "charliermarsh.ruff", // Ruff, btw "DavidAnson.vscode-markdownlint", // Lint markdown files, just like code "EditorConfig.EditorConfig", // For .editorconfig "leonhard-s.python-sphinx-highlight", // Highlight RST elements in docstrings "ms-vscode.hexeditor", // Useful if you don't have a hex editor "ms-python.python", // Pyright, docstrings "njpwerner.autodocstring", // Create docstrings quickly "redhat.vscode-yaml", // For YAML files "swyddfa.esbonio", // RST language server "tamasfe.even-better-toml", // For pyproject.toml "trond-snekvik.simple-rst", // Sphinx rST docs ] } ================================================ FILE: .vscode/settings.json ================================================ // Suggested settings for contributors using VSCode. { "git.enableCommitSigning": true, // nice "Verified" badges @ GH "python.analysis.autoImportCompletions": false, // almost always wrong "python.analysis.importFormat": "relative", // from .module import ... "python.analysis.inlayHints.functionReturnTypes": true, // time saver "python.analysis.inlayHints.variableTypes": false, // almost a PITA "python.analysis.typeCheckingMode": "strict", // pylance strict mode "python.formatting.provider": "black", // the best out there "python.languageServer": "Pylance", // obviously "python.linting.enabled": true, "python.linting.mypyEnabled": true, // ah ofc this shitty typechecker "python.terminal.activateEnvironment": false, // venvs don't deactivate properly "python.terminal.activateEnvInCurrentTerminal": true, // save my time instead "python.testing.pytestEnabled": true, // use pytest, and... "python.testing.unittestEnabled": false // not unittest } ================================================ FILE: .vscode/tasks.json ================================================ { // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format "version": "2.0.0", "tasks": [ { "label": "coverage: all", "type": "shell", "command": "coverage run -m pytest && coverage combine && coverage report && coverage html && start htmlcov/index.html", "problemMatcher": [] }, { "label": "sphinx: clean run", "type": "shell", "command": "./docs/make.bat clean && ./docs/make.bat html && start ./docs/_build/index.html", "problemMatcher": [] } ] } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.2.1] - 2023-06-05 ### Fixed - Python 3.8 compatibility. ## [2.2.0] - 2023-05-28 ### Changed - All event parsing happens during `pyflp.parse` itself. - `colour.Color` replaced with `pyflp.types.RGBA`. - Increase *line-length* of 100. ### Fixed - Backtracking issues in nested dictionaries. ### Removed - Python 3.7 support. - Bunch of intermediate `EventBase` subclasses. - Removed dependency on `colour` library. ## [2.1.1] - 2023-05-24 ### Changed - Refactored `VSTPluginEvent` sub-event handling into `_VSTPluginProp`. - All `VSTPluginEvent` string sub-events decoded as UTF8. ### Fixed - `VSTPlugin.name` encoded in UTF8 [#150]. [#150]: https://github.com/demberto/PyFLP/issues/150 ## [2.1.0] - 2023-04-18 ### Added - Plugin data parsers: `FruitKick` and `Plucked`. - `ArrangementsID.PLSelection` [#132]. ### Changed - Unbound descriptors return `self` - more `property`*esque* behaviour. This is primarily done to allow `flpinspect` to inspect descriptor types. - Moved `Sampler.pitch_shift` upto its base class `_SamplerInstrument`. ### Deprecated - `ArrangementID.LoopPos` [#132]. [#132]: https://github.com/demberto/PyFLP/issues/132 ## [2.0.0] - 2023-03-18 Welcome PyFLP 2.0 🎉 Read the previous changelogs to get the complete list of changes. ### Added - `FruityBloodOverdrive` - thanks to @@ttaschke [#120]. ### Changed - Docs are way more easier to navigate now. ### Fixed - `VSTPluginEvent.__setitem__` and `_VSTPluginProp._set` [#113]. ### Removed - Support for PyPy 3.7 (unable to run tox, cannot find a download). [#113]: https://github.com/demberto/PyFLP/issues/113 [#120]: https://github.com/demberto/PyFLP/pull/120 ## [2.0.0a7] - 2022-12-19 ### Added - `Pattern` timemarkers [#27]. - Low-level API support for FL Studio 21's `PlaylistEvent` [#108]. ### Changed - Renamed `PlaylistEvent.track_index` to `PlaylistEvent.track_rvidx`. - Optimized `Arrangement.tracks` iteration logic - 50% lesser time to run tests. - `StructEventBase.value` raises `NotImplementedError`. - Ambiguous `Pattern.__iter__` refactored into a property `Pattern.notes`. - `Pattern.index` renamed to `Pattern.iid`. - Improved `__repr__` strings; replaced with `ModelReprMixin` at some places use `__str__` for a more human readable representation. ### Fixed - `Patterns.__getitem__` didn't work with pattern names as documented. ### Removed - Ambiguous `__index__` methods from a bunch of model classes. - Unimplemented `Slot.controllers`. [#27]: https://github.com/demberto/PyFLP/issues/27 [#108]: https://github.com/demberto/PyFLP/issues/108 ## [2.0.0a6] - 2022-11-19 ### Added - `Keyboard.main_pitch`, `Keyboard.add_root`, `Keyboard.key_region` [#92]. - `Sampler.filter` and `Filter` [#99]. ### Changed - `Channel.group` becomes a read-only property (modify event to change channel group). - `PLItemBase.offsets` and its fields in `PlaylistEvent` are [float32](https://stackoverflow.com/a/74247360/) Thanks to `chrslg` from Stackoverflow and @jubabrut. - `Track.height` returns an `str` of its percentage e.g. `100%`. - `Instrument.plugin` and `Slot.plugin` return `_PluginBase` for unimplemented native plugins [#102]. - Reimplemented `EventTree` to use a list and got a 10+% perf boost in unit tests. ### Fixed - `Channel.group` remained unitialised [#100]. - `Chanel.plugin` failed due to base class type parameter check [#101]. ### Removed - `Track.locked_height` as what this quantity stores is unknown to me yet. - Use of fixture factories in unittests [#74]. [#74]: https://github.com/demberto/PyFLP/issues/74 [#92]: https://github.com/demberto/PyFLP/issues/92 [#99]: https://github.com/demberto/PyFLP/issues/99 [#100]: https://github.com/demberto/PyFLP/issues/100 [#101]: https://github.com/demberto/PyFLP/issues/101 [#102]: https://github.com/demberto/PyFLP/issues/102 ## [2.0.0a5.post] - 2022-10-31 ### Changed - Upgrade `construct-typing` to 0.5.3. ## [2.0.0a5] - 2022-10-28 ### Added - Implementation for `Channel` and `Pattern` playlist items [#84]. - `FX.remove_dc`, `FX.trim`, `FX.fix_trim`, `FX.crossfade`, `FX.length`, `FX.normalize`, `FX.inverted`, `FX.start` [#55]. - Normalized linear values for certain properties, more user friendly to deal with. The required encode / decode is done at event level itself. - `TimeStretching.time`, `TimeStretching.pitch`, `TimeStretching.multiplier` [#87]. - (Undiscovered) `MIDIControllerEvent`. - `Delay.mod_x`, `Delay.mod_y`, `Delay.fat_mode` and `Delay.ping_pong` [#88]. - Improve enum performance by using `f-enum` library (`pyflp.parse` is 50% faster). - `Time.gate`, `Time.shift` and `Time.full_porta` [#89]. - *Experimental* Python 3.11 support is back. - A shit ton of flags in `VSTPlugin` and refactoring [#95]. - `WrapperEvent.page`, `WrapperEvent.height`, `WrapperEvent.width` [#93]. - `ItemModel.__setitem__` propagates back changes to owner event [#97]. ### Changed - `PlaylistItemBase.offsets` now returns start and end offsets. - Use git commit for `construct-typing` which has fixed certain bugs. - Rename `PlaylistItemBase` to `PLItemBase` and `PatternPlaylistItem` to `PatternPLItem`. - Rename `Polyphony` members `is_mono` to `mono` and `is_porta` to `porta`. - `NoModelsFound` also bases `LookupError` now. - Compiled `VSTPluginEvent.STRUCT`. ### Fixed - `EventTree.divide` fails to yield the only element [#90]. - `TrackID.Name` events were grouped instead of getting divided [#96]. - `PropBase.__set__` always raises `PropertyCannotBeSet` [#97]. ### Removed - `PlaylistItemBase.start_offset` and `PlaylistItemBase.end_offset`. - Redundant exceptions `ExpectedValue`, `UnexpectedType`. - Undiscovered `num_inputs`, `num_outputs` and `vst_number` from `VSTPlugin`. [#55]: https://github.com/demberto/PyFLP/issues/55 [#84]: https://github.com/demberto/PyFLP/issues/84 [#87]: https://github.com/demberto/PyFLP/issues/87 [#88]: https://github.com/demberto/PyFLP/issues/88 [#89]: https://github.com/demberto/PyFLP/issues/89 [#90]: https://github.com/demberto/PyFLP/issues/90 [#93]: https://github.com/demberto/PyFLP/issues/93 [#95]: https://github.com/demberto/PyFLP/issues/95 [#96]: https://github.com/demberto/PyFLP/issues/96 [#97]: https://github.com/demberto/PyFLP/issues/97 ## [2.0.0a4] - 2022-10-22 The way models were passed events has changed. I designed a new data structure called `EventTree` (check `pyflp._events`) to allow the insertion and deletion of events like a list while preserving the speed of a dict lookups. Sounds *awfully* like `multidict` except that it doesn't allow mutable views. `EventTree` knows its parents and any attempt to insert or delete an event from it will also affect its parents *and vice-versa*. Took quite some to do. `EventTree` will allow for insertion / removal of events when corresponding descriptor setters / deleters (yet to implement) are invoked. This can allow for wonderful things like creating new channels, moving inserts etc. ### Added - A multidict with mutable dict view `EventTree`. - PyPy 3.7+ support [#77]. - Slicing for ModelBase collections [#31]. - Fruity Center parser [#42]. - Dependency on `sortedcontainers` library for `EventTree`. - Remaining and some new images for docstrings [#47]. - GUI locations of descriptors (w.r.t. FL 20.8.4) [#80]. ### Changed - Simplified some `__repr__` strings. - Event IDs are all `EventEnum` members (better repr-strings). - PyFLP is guaranteed to be not thread-safe. - Moved up `Sampler.cut_group` to `_SamplerInstrument`. ### Fixed - `ModelReprMixin`. ### Removed - `Track.index` in favour of the redundant `Track.__index__`. - `Track.items`. Iterate over a track, to get them now. - Subclassing of protocol classes keeping [PEP544] in mind [#50]. - Models are no longer hashable as events were made unhashable previously. - Commented out currently unimplemented `Channel.controllers`. [#31]: https://github.com/demberto/PyFLP/issues/31 [#42]: https://github.com/demberto/PyFLP/discussions/42 [#47]: https://github.com/demberto/PyFLP/issues/47 [#50]: https://github.com/demberto/PyFLP/issues/50 [#77]: https://github.com/demberto/PyFLP/issues/77 [#80]: https://github.com/demberto/PyFLP/issues/80 [PEP544]: https://peps.python.org/pep-0544 ## [2.0.0a3] - 2022-10-08 ### Added - 100% mypy tested *for all you mypy geeks*. It makes me play cat-and-mouse. - `Automation` points and LFO, via [#29]. ### Changed - All `StructBaseEvent` classes overhauled to use the `construct` library. - `EventBase.__len__` is now `EventBase.size`, a property. - Shift all subclass event parsing to `PODEventBase`. - Replace all uses of `bytesioex` with equivalents from `construct`. - Struct definitions moved to `StructEventBase` itself. - Enums used in structs directly now inherit from `construct_typed.EnumBase`. - `LFO` renamed to `SamplerLFO` to be distinguishable from `AutomationLFO`. ### Fixed - `InsertEQ` was't working [#46]. - Negative `FileFormat` weren't being read. - Incorrect event size calculation in `StructEventBase` [#72]. - `Pattern.__repr__` failed for empty patterns. ### Removed - `_StructMeta` (voodoo magic) and `StructBase` from `pyflp._events`. - `SoundgoodizerMode`, `FruityFastDistKind`, `StereoEnhancerInvertPosition`, `StereoEnhancerEffectPosition` from `pyflp.plugin` in favour of equivalent string literals. - Protocol subclassing of `EventBase` hierarchy. - Faulty `EventBase.__hash__`. - Python 3.11 support due to - Incomplete support for `Sequence` in model collections. [#29]: https://github.com/demberto/PyFLP/issues/29 [#46]: https://github.com/demberto/PyFLP/issues/46 [#72]: https://github.com/demberto/PyFLP/issues/72 ## [2.0.0a2] - 2022-10-01 ### Added - `FX.clip`, `FX.fade_stereo`, `FX.freq_tilt`, `FX.pogo`, `FX.ringmod`, `FX.swap_stereo` & `FX.reverse` [#55]. - `TimeStretching.mode` and `StretchMode` [#56]. - `Playback.start_offset` [#57]. - `Content.declick_mode` and `DeclickMode` [#58]. - User guide and contibutor's guide. - Official support for Python 3.11. - Super basic `__repr__` for `StructBase` to ease debugging. - `Envelope.amount`, `Envelope.synced`, `LFO.amount`, `LFO.attack`, `LFO.predelay` & `LFO.speed` [#69]. ### Changed - Moved `stretching` to `Sampler`, instruments don't have it. - `Note.key` now returns a note name with octave [#66]. - A cleaner implementation of `MixerParamsEvent`. - `Layer.__repr__` now shows the number of children also. - Separated test assets into presets for better isolation of results [#6]. - Renamed `LFO.is_synced` to `LFO.synced` and `LFO.is_retrig` to `LFO.retring`. - `StructBase` and `ListEventBase` are lazily evaluated now. - Model collections are indexable by item names as well [#45]. ### Fixed - String are decoded as UTF16 when version is 11.5+ now [#65]. - `Insert.stereo_separation` docstring for maximum, minimum value. - `U16TupleEvent.value` [#68]. - Minimum and maximum value docstrings for certain `FX` properties. - `Sampler.pitch_shift` internal representation. ### Removed - Images for individual FX properties as they were redundant. - Redundant member `_SamplerInstrument.flags`. [#6]: https://github.com/demberto/PyFLP/issues/6 [#45]: https://github.com/demberto/PyFLP/issues/45 [#55]: https://github.com/demberto/PyFLP/issues/55 [#56]: https://github.com/demberto/PyFLP/issues/56 [#57]: https://github.com/demberto/PyFLP/issues/57 [#58]: https://github.com/demberto/PyFLP/issues/58 [#65]: https://github.com/demberto/PyFLP/issues/65 [#66]: https://github.com/demberto/PyFLP/issues/66 [#68]: https://github.com/demberto/PyFLP/issues/68 [#69]: https://github.com/demberto/PyFLP/issues/69 ## [2.0.0a1] - 2022-09-21 ### Added - `PlaylistItemBase.group` for `ChannelPlaylistItem` and `PatternPlaylistItem` [#36]. - More info in contributor's guide. - VSCode Python extension configuration, recommended extensions and tasks. - `ChannelRack.height` which tells the height of the channel rack in pixels. - `Track[x]` returns `Track.items[x]`. - `Patterns` warns when tried to be accessed with an index of 0. - `Note.group`, a number which notes of the same group share [#28]. - `Note.slide` which indicates whether a note is a sliding note. - Plugin wrapper properties to docs. - A user guide section in docs. - `Sampler.content`, `Layer.random` & `Layer.crossfade` [#24]. - `Playback.ping_pong_loop`. ### Changed - `Pattern.notes` refactored into `Pattern.__iter__`. - `Sampler.sample_path` returns `pathlib.Path` instead of `str` now [#41]. - `PluginID.Data` events get parsed during event collection itself. - All models are now equatable and hashable. ### Fixed - `Arrangement` parsing logic is incorrect [#32]. - `Track.color` returns `int` instead of `colour.Color` [#33]. - `_PlaylistItemStruct.track_index` should be 2 bytes [#36]. - Tracks don't get assigned playlist items [#37]. - KeyError when accessing `Track.content_locked` [#38]. - Channel type wasn't correctly detected at times [#40]. - `Arrangements.height` was actually `ChannelRack.height` [#43]. - TypeError when accessing `Insert.dock` [#44]. - `Pattern.note` and `Pattern.controllers` [#48]. - `Track.items` [#49] - Certain properties of `Note` were interpreted incorrectly. - `Slot.plugin` wasn't working at all (events, properties, repr) [#53]. - `FruitySend.send_to` was interepreted incorrectly. - `Instrument.plugin` and `Slot.plugin` setter. - `Playback.use_loop_points`. ### Removed - `Arrangements.height`. [#24]: https://github.com/demberto/PyFLP/issues/24 [#28]: https://github.com/demberto/PyFLP/issues/28 [#32]: https://github.com/demberto/PyFLP/issues/32 [#33]: https://github.com/demberto/PyFLP/issues/33 [#36]: https://github.com/demberto/PyFLP/issues/36 [#37]: https://github.com/demberto/PyFLP/issues/37 [#38]: https://github.com/demberto/PyFLP/issues/38 [#40]: https://github.com/demberto/PyFLP/issues/40 [#41]: https://github.com/demberto/PyFLP/issues/41 [#43]: https://github.com/demberto/PyFLP/issues/43 [#44]: https://github.com/demberto/PyFLP/issues/44 [#48]: https://github.com/demberto/PyFLP/issues/48 [#49]: https://github.com/demberto/PyFLP/issues/49 [#53]: https://github.com/demberto/PyFLP/issues/53 ## [2.0.0a0] - 2022-09-14 PyFLP has been rewritten ✨ Highlights: 1. Richer events: Variable data events now parse their structure themselves. Fixed size events are categorized closely to the data they represent. 2. Lazy evaluation: Properties are evaluated as lazily as possible to prevent the use of private variables and keep them synced with event data. 3. Neatly organised models: Appropriate use of composition and subclassing. 4. Zero pre-parse field validation: Makes sense for an undocumented format. 5. Fully type hinted: Ensures strict adherence with pyright. 6. Simplified single-level module hierarchy to ease imports. 7. Docs now contain images for corresponding model types. *The major version number bump indicates a breaking change, however I would highly encourage you to upgrade to this version. **I WILL NOT BE MAINTAINING OLDER VERSIONS.*** ## 1.1.2 - Unreleased ### Fixed - [#9](https://github.com/demberto/PyFLP/pull/9), thanks to @zacanger. ## [1.1.1] - 2022-07-10 ### Added - Avoid mkdocs warnings in tox. ### Changed - `_FLObject._save` always returns a list now. - CI: Merge `dev` and `publish` workflows into one. ### Fixed - [#8](https://github.com/demberto/PyFLP/issues/8). - Type hints and type variables are much better. - `FSoftClipper` property setter typo caused it to be set to zero. - `ChannelParameters._save()` didn't return an event. ### Removed - Wait action in CI workflow. - `setup-cfg-fmt` pre-commit hook, [why?](https://github.com/asottile/setup-cfg-fmt/issues/147) ## [1.1.0] - 2022-05-29 ### Added - Support for Fruity Stereo Enhancer @@nickberry17 - Instructions for alternate methods to install PyFLP. ### Changed - Improvements to CI ### Fixed - Incorrect encoding used to dump UTF-16 strings in `_TextEvent`. - [#4](https://github.com/demberto/PyFLP/issues/4). ### Removed - `_FLObject.max_count`, `MaxInstancesError`, `test_flobject.py` and `_MaxInstancedFLObject`. - Gitter links from README and room itself, due to inactivity. ## [1.0.1] - 2022-04-02 This update is more about QOL improvements, testing and refactoring. Few bugs have been fixed as well, while Python 3.6 support has been deprecated. ### Added - Adopted `bandit`. - `_MaxInstancedFLObject`: `FLObject` with a limit on number of instances. - GPL3 short license headers. - Missing docs about `PatternNote` and `PatternController` events. - Exceptions: `InvalidHeaderSizeError`, `InvalidMagicError` and `MaxInstancesError`. - Import statements in submodules to simplify import process externally. - Test validators and properties and project version setter. - OTT plugin to test project to test VST plugins. ### Changed - All use of `assert` has been replaced by exceptions (bandit: assert-used). - Version links in changelog now show changes. - LF line endings used and enforced everywhere. - `ppq` field moved to `_FLObject` from `Playlist`. - Much improved `tox.ini` and pre-commit configuration. - Modules which aren't meant for external use are prefixed with a _. - Simplified property declaration. ### Deprecated - Python 3.6 support will be dropped in a future major release. ### Fixed - All this time, `VSTPluginEvent` was never getting created/saved. - Lint errors reported by flake8, pylint and bandit. - Just realised `__setattr__` works only on instances 😅, came up with `_FLObjectMeta` which is the metaclass used by `_FLObject`. ### Removed - Redundant `__repr__` from `PatternNote`. ## [1.0.0] - 2021-11-12 ### **Highlights** - The entire module hierarchy of PyFLP has been simplified. - Internal/abstract base classes have bee renamed to start with _. - `repr` for `_FLObject` subclasses. - The way properties are handled is now completely changed. - Data events get parsed by a `DataEvent` subclass. - Way better testing, with a coverage of whooping 79%. - `color` properties now return a `colour.Color` object. - Almost everything has a docstring now, even enum members. - PyFLP has adopted Contributor Covenant Code of Conduct v2.1. ### Added - `__repr__()` for all `_FLObject` subclasses. - `Channel.color`, `Insert.color` and `Pattern.color` now return `colour.Color`. This is implemented by `ColorEvent` (*which subclasses `DWordEvent`*). - New event implementations for `ChannelFX.EventID` (`Cutoff`, `Fadein`, `Fadeout` and more). - New event implementations for `Channel.EventID` (`ChannelTracking`, `ChannelLevels`, `ChannelLevelOffsets`, `ChannelPolyphony` and more). - `Channel.cut_group` implementing `Channel.EventID.CutSelfCutBy`. - Remote controllers (`RemoteController`). Accessible from `Project.controllers`. - Saving for `VSTPlugin`. - All enum members used by `FLObject` subclasses now have a docstring. - Added links in docstrings to official FL Studio Manual wherever possible. - `Parser.__build_event_store()` uses inner methods now to parse different kind of events; very helpful for the new `DataEvents`. - Added support for pattern controller events (`PatternController`, `PatternControllerEvent` who implement `PatternEventID.Controllers`). - Many attribute docstrings now include minimum, maximum and default values. **These limits are enforced by setters**. - Added `.editorconfig`, *using CRLF line endings btw*. - Added `test_parser.py` and `test_events.py`. - `Parser.parse_zip` now accepts a `bytes` object for `zip_file` parameter. - `Misc.registered` for `Misc.EventID.Registered`. ### Changed - All `_FLObject` subclasses have been moved to parent `pyflp/` from `pyflp/flobject/` to ease import names. - All `Event` subclasses have been moved in a single `event.py` and `event/` folder is removed. - All event ID enum names are now inner classes of `_FLObject` subclasses. - Constructor of `Project` has been simplified. - `VSTPlugin`'s underlying event now supports saving, it has been refactored out of `_parse_data_event` also. - `InsertParametersEvent` to replace the equivalent parsing in `Insert._parse_data_event`. - The `TODO` *(deleted now)* has been changed to reflect the type of goals. - `_FLObject.save` is now `_FLObject._save`. - Some constants present in `utils.py` have been moved to `constants.py`. - Docs include a brief summary of the underlying data event wherever applicable. - Minor property name changes; made them more concise. - Absolute imports are used everywhere now. ### Fixed - `ChannelFXReverb` was not getting initialised. - `InsertParamsEvent` was not getting initialised. - Syntax is highlighted in the [docs](https://pyflp.rtfd.io/) as expected now. - `FNotebook2` text parsing. - `Insert.routing` returned `True` for all tracks. - `Misc.start_date` and `Misc.work_time` parsing. ### Removed - Any and all sort of logging, not useful anymore. Haven't seen any 3rd party Python library ever using it. Used `warnings` wherever necessary. - `mypy`. Its useless tbh, I will use types as I see fit. - Setters for all properties containing `_FLObject` (or any sort of a collection of them), *e.g. Arrangement.tracks*. ## [0.2.0] ### **Highlights** - **PyFLP has passed the null test for a full project of mine (FL 20.7.2) 🥳**. - This library uses code from [FLParser](https://github.com/monadgroup/FLParser), a GPL license project, PyFLP is now under GPL. - API reference documentation is complete now. - Few new events implemented for `Channel`. - Refactored `FLObject` and `Plugin`. #### `FLObject` refactoring - `parseprop` is now `_parseprop`. - All `_parseprop` delegates are now "protected" as well. - `setprop` is now `_setprop`. ### Added - `ChannelEvent.Delay` is implemented by `ChannelDelay` and `Channel.delay`. - `Event.to_raw` and `Event.dump` now log when they are called. - Exceptions `DataCorruptionDetected` and `OperationNotPermitted`. ### Fixed - Can definitely say, all naming inconsistencies have been fixed. - Fixed `TimeMarker` assign to `Arrangement` logic in `Parser`. - Extraneous data dumped sometimes by `InsertSlotEvent.Plugin`, caused due to double dumping of same events. - Empty pattern events, `PatternEvent.Name` and `PatternEvent.Color` don't get saved. --- ❗ These versions below don't work due to naming inconsistencies 😅, you will not find them 👇 ## [0.1.2] ### Added - More docs. - Add some new properties/events to `Channel`. - A sample empty FLP has been provided to allow running tests. - All `FLObject` subclasses now have a basic `__repr__` method. ### Fixed - Improve the GitHub workflow action, uploads to PyPI will not happen unless the test is passed. - ~~Fix all naming inconsistencies caused due to migration to [`BytesIOEx`](https://github.com/demberto/BytesIOEx)~~ Not all. ### Known issues Same as in 0.1.1 ## [0.1.1] ~~The first version of PyFLP that works correctly 🥳~~ No, unfortunately ### **Highlights** - Changed documentation from Sphinx to MkDocs. - [FLPInfo](https://github.com/demberto/FLPInfo) is now a separate package. - FLPInspect is now a separate package. - PyFLP now uses [BytesIOEx](https://github.com/demberto/BytesIOEx/) as an external dependency. ### Fixed - `ByteEvent`, `WordEvent` and `DWordEvent` now raise a `TypeError`. when they are initialised with the wrong size of data. - Fix setup.cfg, project structure is now as expected, imports will work. - [Docs](https://pyflp.rtfd.io/) are now up and running. ### Known issues - Extraneous data dumped sometimes by `InsertSlotEvent.Plugin`, why this is caused is not known. --- **❗ These versions below don't work because I didn't know how to configure `setup.cfg` properly 😅** ## 0.1.0 - `flpinspect` - An FLP Event Viewer made using Tkinter. - `flpinfo` - A CLI utility to get basic information about an FLP. - Switched to MIT License. ### Added - Lots of changes, refactoring and code cleanup of `pyflp`. - New docs. - Changes to `README`. - Adopted [`black`](https://github.com/psf/black) coding style. - Added a `log_level` argument to `Parser`. - `Project.create_zip` copies stock samples as well now. - `Project.get_events` for getting just the events; they are not parsed. Read [docs](https://pyflp.rtfd.io) for more info about this. - `Event` classes now have an `__eq__` and `__repr__` method. ### Fixed - Tests don't give module import errors. - `Pattern` event parsing. - Initialise `_count` to 0, everytime `Parser` is initialised. - `Project.create_zip` now works as intended. - Overhauled logging. - A lot of potential bugs in `FLObject` subclasses. ### Known issues - `flpinfo` doesn't output correctly sometimes due to long strings. - Extraneous data dumped sometimes by `InsertSlotEvent.Plugin`, why this is caused is not known. [2.2.1]: https://github.com/demberto/PyFLP/compare/v2.2.0...v2.2.1 [2.2.0]: https://github.com/demberto/PyFLP/compare/v2.1.1...v2.2.0 [2.1.1]: https://github.com/demberto/PyFLP/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/demberto/PyFLP/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/demberto/PyFLP/compare/v2.0.0a7.post0...v2.0.0 [2.0.0a7]: https://github.com/demberto/PyFLP/compare/v2.0.0a6...v2.0.0a7 [2.0.0a6]: https://github.com/demberto/PyFLP/compare/v2.0.0a5.post...v2.0.0a6 [2.0.0a5.post]: https://github.com/demberto/PyFLP/compare/v2.0.0a5...v2.0.0a5.post [2.0.0a5]: https://github.com/demberto/PyFLP/compare/v2.0.0a4...v2.0.0a5 [2.0.0a4]: https://github.com/demberto/PyFLP/compare/v2.0.0a3...v2.0.0a4 [2.0.0a3]: https://github.com/demberto/PyFLP/compare/v2.0.0a2...v2.0.0a3 [2.0.0a2]: https://github.com/demberto/PyFLP/compare/v2.0.0a1...v2.0.0a2 [2.0.0a1]: https://github.com/demberto/PyFLP/compare/v2.0.0a0...v2.0.0a1 [2.0.0a0]: https://github.com/demberto/PyFLP/compare/v1.1.1...v2.0.0a0 [1.1.1]: https://github.com/demberto/PyFLP/compare/1.1.0...v1.1.1 [1.1.0]: https://github.com/demberto/PyFLP/compare/1.0.1...1.1.0 [1.0.1]: https://github.com/demberto/PyFLP/compare/1.0.0...1.0.1 [1.0.0]: https://github.com/demberto/PyFLP/compare/0.2.0...1.0.0 [0.2.0]: https://github.com/demberto/PyFLP/compare/0.1.2...0.2.0 [0.1.2]: https://github.com/demberto/PyFLP/compare/0.1.1...0.1.2 [0.1.1]: https://github.com/demberto/PyFLP/releases/tag/0.1.1 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [demberto@protonmail.com](demberto@protonmail.com). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: MANIFEST.in ================================================ prune .github exclude .all-contributorsrc exclude .gitignore exclude .pre-commit-config.yaml exclude .readthedocs.yaml ================================================ FILE: README.md ================================================ # PyFLP PyFLP is an unofficial parser for [FL Studio](https://www.image-line.com/fl-studio/) project and preset files written in Python.
CI Documentation Build Status pre-commit-ci
PyPI PyPI - Package Version PyPI - Supported Python Versions PyPI - Supported Implementations PyPI - Wheel
Activity Maintenance PyPI - Downloads
QA codecov CodeFactor Grade Checked with mypy pre-commit Security Status
Other License GitHub top language Code Style: Black covenant
From a very general point-of-view, this is the state of what is currently implemented. Click on a link to go to the documentation for that feature.
Group Feature Issues
Arrangements
open arrangement-general issues closed arrangement-general issues
🎼 Playlist open arrangement-playlist issues closed arrangement-playlist issues
🎞️ Tracks open arrangement-track issues closed arrangement-track issues
Channel Rack
open channel-general issues closed channel-general issues
📈 Automations open channel-automation issues closed channel-automation issues
🎹 Instruments channel-instrument issues closed channel-instrument issues
📚 Layer open channel-layer issues closed channel-layer issues
📁 Sampler open channel-sampler issues closed channel-sampler issues
Mixer
open mixer-general issues closed mixer-general issues
🎚️ Inserts open mixer-insert issues closed mixer-insert issues
🎰 Effect slots open mixer-slot issues closed mixer-slot issues
🎶 Patterns
open pattern-general issues closed pattern-general issues
🎛 Controllers open pattern-controller issues closed pattern-controller issues
🎵 Notes open pattern-note issues closed pattern-note issues
🚩 Timemarkers open timemarker issues closed timemarker issues
Plugins Native - 8 effects, 1 synth open plugin-native issues closed plugin-native issues
VST 2/3 plugin-3rdparty issues closed plugin-3rdparty issues
Project - Settings and song metadata open project-general issues closed project-general issues
## ⏬ Installation CPython 3.8+ / PyPy 3.8+ required. ```none python -m pip install -U pyflp ``` ## ▶ Usage [Load](https://pyflp.readthedocs.io/en/latest/reference.html#pyflp.parse) a project file: ```py import pyflp project = pyflp.parse("/path/to/parse.flp") ``` > If you get any sort of errors or warnings while doing this, please open an > [issue](https://github.com/demberto/PyFLP/issues). [Save](https://pyflp.readthedocs.io/en/latest/reference.html#pyflp.save) the project: ```py pyflp.save(project, "/path/to/save.flp") ``` > It is advised to do a backup of your projects before doing any changes. > It is also recommended to open the modified project in FL Studio to ensure > that it works as intended. Check the [reference](https://pyflp.rtfd.io/en/latest/reference.html) for a complete list of useable features. ## 🙏 Acknowledgements - Monad.FLParser: - FLPEdit (repo deleted by [author](https://github.com/roadcrewworker)) ## ✨ Contributors ![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square) Thanks goes to these wonderful people:

nickberry17

💻

zacanger

🐛 📖

Tim

📖 💻 🚧
This project follows the [all-contributors](https://allcontributors.org/) specification. Contributions of any kind are welcome! Please see the [contributor's guide](https://pyflp.rtfd.io/en/latest/contributing.html) for more information about contributing. ## 📧 Contact You can contact me either via [issues](https://github.com/demberto/PyFLP/issues) and [discussions](https://github.com/demberto/PyFLP/discussions) or through email via ``demberto(at)proton(dot)me``. ## © License The code in this project has been licensed under the [GNU Public License v3](https://www.gnu.org/licenses/gpl-3.0.en.html). ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/architecture/flp-format.rst ================================================ Part I: FLP Format & Events =========================== FLP is a binary format used by Image-Line FL Studio, a music production software, to store project files. Instead of using C-style structs entirely, the FLP format has evolved from what once was a MIDI-like format to a really bad and messy combination of :wikipedia:`Type-length-value` encoded "events" and structs. Specification ------------- An FLP file contains of basically 2 sections or "chunks", one is the header and other is the "data" section, which contains all the "events". Header chunk ^^^^^^^^^^^^ .. tab-set:: .. tab-item:: C / C++ .. code-block:: c struct { char magic[4]; // 'FLhd' uint32_t size; // always been 6 int16_t format; // Internal file format uint16_t num_channels; // Number of channels in channel rack uint16_t ppq; // Pulses per quarter } .. tab-item:: Python .. code-block:: python class Header: magic: str size: int format: int num_channels: int ppq: int .. currentmodule:: pyflp.project .. seealso:: :attr:`Project.format`, :attr:`Project.channel_count`, :attr:`Project.ppq` Data chunk ^^^^^^^^^^ .. code-block:: c struct { char magic[4]; // 'FLdt' uint32_t size; // Total combined size of events void* events; // Event data } .. _architecture-event: Event ----- An event can be thought of as a "flattened" :class:`dict` of attributes composing a class. It can *roughly* be represented as: .. tab-set:: .. tab-item:: C / C++ .. code-block:: c struct { uint8_t type; void* value; } .. tab-item:: Python .. code-block:: python class Event: type: int value: object Types ^^^^^ There are basically 4 kinds of events depending on the range of ``type``: +----------+--------------------+-----------------+ | Event ID | Size of ``value`` | Total event size | +==========+===================+==================+ | 0-63 | 1 byte | 1 + 1 = **2** | +----------+-------------------+------------------+ | 64-127 | 2 bytes | 1 + 2 = **3** | +----------+-------------------+------------------+ | 128-191 | 4 bytes | 1 + 4 = **5** | +----------+-------------------+------------------+ | 192-255 | Length prefixed | >= 2 | +----------+-------------------+------------------+ .. note:: Length prefixed events These events store the length of the ``value`` they contain after ``type`` in a varint. It can be considered as the only true TLV encoded event type. .. code-block:: c struct { uint8_t type; // 192-255 uint8_t* length; // varint void* value; // string, struct or subevent } It should be clearer by now how the FLP format is a misfit for the data it represents. Representation ^^^^^^^^^^^^^^ Event IDs 0-191 are used for storing fixed size data like integers, floats and booleans. IDs from 192-255 are used for storing structs, subevents and strings. ================================================ FILE: docs/architecture/how-it-works.rst ================================================ Part II: How PyFLP works ======================== 💡 You should read Part I before this. PyFLP's entry-point :meth:`pyflp.parse` verifies the headers and parses all the events. These events are collected into an :class:`pyflp._events.EventTree`. Schematic diagram ----------------- .. svgbob:: :align: center ┌──────────────────────────────────────────────────────────────────────────┐ │ Events - binary representation - low level API - Stage 1 parser │ │ │ │ ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────┐ │ │ │ Project-wide / 1-time │ │ Per-instance │ │ Shared │ │ │ │┌─────────┐ ┌─────────┐│ │┌─────────┐ ┌─────────┐│ │ ┌─────────┐ │ │ │ ││ Event 1 │ │ Event 2 ││ ││ Event 3 │ │ Event 4 ││ │ │ Event 5 │ │ │ │ ││ id: 199 │ → │ id: 159 ││→││ id: 64 │ → │ id: 215 ││→│ │ id: 225 │ │ │ │ ││ string │ │ integer ││ ││ integer │ │ struct ││ │ │ AoS │ │ │ │ │└─────────┘ └─────────┘│ │└─────────┘ └─────────┘│ │ └─────────┘ │ │ │ └─────│──────────────│────┘ └──────│────────────│─────┘ └──────│──────┘ │ │ └──┬───────────╯╭────────────┴────────────╯ │ │ │ ┌───────────────┐ ┌───────┬──────────┬──────────────┐ ┌────────────────┐ │ │ │ Model A │ │ Model │ Model B1 │ attr_64: int │ │ Model C1: e[0] │ │ │ │ attr_199: str │ │ list ├──────────┼──────────────┤ ├────────────────┤ │ │ │ attr_159: int │ │ of B │ Model B2 │ attr_215: X │ │ Model C2: e[1] │ │ │ └───────────────┘ └───────┴──────────┴──────────────┘ └────────────────┘ │ │ │ │ Models - PyFLP's representation - high level API - Stage 2 parser │ └──────────────────────────────────────────────────────────────────────────┘ PyFLP provides a high-level and a low-level API. Normally the high-level API should get your work done. However, it might be possible that due to a bug or super old versions of FLPs the high level API fails to parse. In that case, one can use the low-level API. Using it requires a deeper understanding of the FLP format and how the GUI hierarchies relate to their underlying events. What it does? ------------- In a nutshell, PyFLP parses the events and creates a better semantic structure from it (as shown in the above diagram; stage 2 parser). I call this a "model". .. _architecture-model: Model ----- A model acts like a "view" or alternate representation of the event data. It has no state of its own and its composed of descriptors which get and set values from the events directly. A model is essentially stateless. This has some advantages as compared to stateful models: 1. The underlying event data and the values returned from the model descriptors *i.e. its attributes or properties* always remain in sync with each other. 2. Since modifying the event data at a binary level means conforming to the various size and range limits imposed by C's data types, it can act as basic validation for no extra cost or implementation. 3. Avoid the use of private members in the models itself. Private members maybe a good idea in languages which have better implementation of such concepts, but in Python its quite as good as shooting yourself in the foot. Due to Python's do-whatever-you-want nature, it can lead to some very bad coding practices. This is one of the big reasons why PyFLP underwent a rewrite. 4. Nothing is done in class constructor, so if a particular set of events are out of order or follow a sequence not yet understood by PyFLP, they will fail only for the attributes which use them. Hence, what is *parseable* can still be parsed. This lazy evaluation can be good and bad both, but with adequate unit tests its more good than it is bad. Creating a model involves a good amount of reverse engineering and insight. The models PyFLP has are based as close to the GUI objects inside FL Studio. For e.g. a pattern is represented by :class:`pyflp.pattern.Pattern`. A model is constructed with events it requires and additional information (like PPQ) its descriptors might need. ================================================ FILE: docs/architecture/reference.rst ================================================ Developer Reference =================== This page documents PyFLP's internals which consists of :mod:`pyflp._events`, :mod:`pyflp._descriptors` and :mod:`pyflp._models`. The content below assumes you have fairly good knowledge of the following: - OOP and descriptors, especially - Type annotations - Binary data types and streams Events ------ .. automodule:: pyflp._events If you have read Part I, you know how events use a TLV encoding scheme. Type ^^^^ The ``type`` represents the event ID. A custom enum class (and a metaclass) supporting unknown IDs and member check using Python's ``... in ...`` syntax is used. .. autoclass:: _EventEnumMeta :members: .. autoclass:: EventEnum :members: Length ^^^^^^ The ``length`` is a field prefixed for IDs in the range of 192-255. It is the size of ``value`` and is encoded as a VLQ128 (variable length quantity base-128). Value ^^^^^ Below are the list of classes PyFLP has, grouped w.r.t the ID range. .. dropdown:: 0-63 .. autoclass:: ByteEventBase .. autoclass:: U8Event .. autoclass:: BoolEvent .. autoclass:: I8Event .. dropdown:: 64-127 .. autoclass:: WordEventBase .. autoclass:: U16Event .. autoclass:: I16Event .. dropdown:: 128-191 .. autoclass:: DWordEventBase .. autoclass:: U32Event .. autoclass:: I32Event .. autoclass:: ColorEvent .. autoclass:: U16TupleEvent .. dropdown:: 192-255 .. autoclass:: StrEventBase .. autoclass:: AsciiEvent .. autoclass:: UnicodeEvent .. autoclass:: StructEventBase .. autoclass:: ListEventBase .. autoclass:: UnknownDataEvent EventTree ^^^^^^^^^ .. autoclass:: EventTree :members: Models ------ .. automodule:: pyflp._models Implementing a model ^^^^^^^^^^^^^^^^^^^^ A look at the **source code** will definitely help, although these are a few points that must be kept in mind when Implementing a model: 1. Does the model mimic the hierarchy exposed by FL Studio's GUI? .. tip:: Browse through the hierarchies of :class:`pyflp.channel.Channel` subclasses to get a very good idea of this. 2. Are ``__dunder__`` methods provided by Python used whenever possible? 3. Is either :class:`ModelReprMixin` subclassed or ``__repr__`` implemented? Descriptors ----------- .. automodule:: pyflp._descriptors Adapters -------- .. automodule:: pyflp._adapters ================================================ FILE: docs/architecture.rst ================================================ 🏠 Architecture ================ .. toctree:: 1️⃣ FLP Format & Events 2️⃣ How it works? 3️⃣ Developer Reference ================================================ FILE: docs/changelog.rst ================================================ .. mdinclude:: ../CHANGELOG.md ================================================ FILE: docs/conf.py ================================================ # type: ignore """Sphinx configuration script.""" from __future__ import annotations import enum import importlib.metadata import inspect import re import m2r2 from pyflp._descriptors import EventProp, FlagProp, NestedProp, StructProp from pyflp._events import EventEnum, RGBA from pyflp._models import ModelBase from pyflp.arrangement import _TrackColorProp BITLY_LINK = re.compile(r"!\[.*\]\((https://bit\.ly/[A-z0-9]*)\)") """Shortened URLs for links to in-docstring images and docs.""" NEW_IN_FL = re.compile(r"\*New in FL Studio v([^\*]*)\*[\.:](.*)") """Matched in docstrings and replaced with an SVG by :meth:`badge_flstudio`.""" EVENT_ID_DOC = re.compile(r"([0-9\.]*)\+") FL_BADGE = "https://img.shields.io/badge/FL%20Studio-{}+-5f686d?labelColor=ff7629&style=for-the-badge" # noqa GHUC_PREFIX = "https://raw.githubusercontent.com/demberto/PyFLP/master/docs/" """Raw image URL root used for in-docstring images and docs.""" IGNORED_BITLY = ["3RDM1yn"] project = "PyFLP" author = "demberto" copyright = f"2022, {author}" release = importlib.metadata.version("pyflp") # Needs package installation! extensions = [ "hoverxref.extension", "m2r2", # Markdown to reStructuredText conversion "sphinx_copybutton", # Copy button for code blocks "sphinx_design", # Grids, cards, icons and tabs "sphinxcontrib.spelling", # Catch spelling mistakes "sphinxcontrib.svgbob", # ASCII diagrams -> SVG "sphinx.ext.autodoc", # Sphinx secret sauce "sphinx.ext.autosummary", # Summary of contents table "sphinx.ext.coverage", # Find what I missed to autodoc "sphinx.ext.duration", # Time required to build docs "sphinx.ext.intersphinx", # Automatic links to Python docs "sphinx.ext.napoleon", # Google-style docstrings "sphinx.ext.todo", # Items I need to document "sphinx.ext.viewcode", # "Show source" button next to autodoc output "sphinx_toolbox", # Badges and goodies "sphinx_toolbox.github", # Link to project issues / PRs easily "sphinx_toolbox.more_autodoc.autoprotocol", # Autodoc extension for typing.Protocol "sphinx_toolbox.more_autodoc.sourcelink", # Python docs-style source code link "sphinx_toolbox.sidebar_links", # Links to repo and PyPi project in the sidebar "sphinx_toolbox.wikipedia", # Diretive for wikipedia topics. ] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] html_theme = "furo" # Nice light/dark theme; has an auto-switch mode autodoc_inherit_docstrings = False autodoc_default_options = { "undoc-members": True, # Show undocumented members "exclude-members": "INTERNAL_NAME", # Exclude these members "no-value": True, # Don't show a default value (for descriptors mainly) } needs_sphinx = "5.0" hoverxref_auto_ref = True # Convert all :ref: roles to hoverxrefs napoleon_preprocess_types = True napoleon_attr_annotations = True html_permalinks_icon = "#" # Get rid of the weird paragraph icon github_username = author # sphinx_toolbox.github config github_repository = project # sphinx_toolbox.github config autodoc_show_sourcelink = True # sphinx_toolbox.more_autodoc.sourcelink todo_include_todos = True # Include .. todo:: directives in output todo_emit_warnings = True # Emit warnings about it as well, so I don't forget html_css_files = [ "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.3.0/css/all.min.css" ] # https://sphinx-design.rtfd.io/en/furo-theme/badges_buttons.html#fontawesome-icons sd_fontawesome_latex = True # Output FontAwesome icons in LaTeX intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "construct": ("https://construct.readthedocs.io/en/latest", None), } # Put hyperlinks to docs of other projects linkcheck_allowed_redirects = { r"https://bit.ly/.*": r"https://raw.githubusercontent.com/demberto/PyFLP/master/docs/img/.*", # noqa r"https://pyflp.rtfd.io.*": r"https://pyflp.readthedocs.io/en/latest/.*", r"https://www.python.org/dev/peps/.*": r"https://peps.python.org/.*", r"https://github.com/demberto/PyFLP/files/.*": r"https://objects.githubusercontent.com/.*", # noqa r"https://stackoverflow.com/a/.*": r"https://stackoverflow.com/questions/.*", } def badge_flstudio(app, what, name, obj, options, lines): """Convert FL Studio version information in docstrings to nice badges.""" for line in lines: if name.split(".")[-2].endswith("ID"): # Event ID member match = EVENT_ID_DOC.fullmatch(line) else: match = NEW_IN_FL.fullmatch(line) if match is not None: groups = tuple( filter( lambda group: group != "", map(lambda group: group.strip(), match.groups()), ) ) if len(groups) == 1: lines.insert(0, f".. image:: {FL_BADGE.format(groups[0])}") lines.insert(1, "") elif len(groups) == 2: grid = f""" .. figure:: {FL_BADGE.format(groups[0])} :alt: New in FL Studio v{groups[0]} {groups[1].strip()} """ lines[:0] = grid.splitlines() # https://stackoverflow.com/a/25855473 lines.remove(line) def add_annotations(app, what, name, obj, options, signature, return_annotation): """Add type annotations for descriptors.""" if what == "class" and issubclass(obj, ModelBase): annotations = {} for name_, type in vars(obj).items(): if isinstance(type, _TrackColorProp): annotations[name_] = RGBA elif isinstance(type, NestedProp): annotations[name_] = type._type elif isinstance(type, FlagProp): annotations[name_] = bool | None elif hasattr(type, "__orig_class__"): annotations[name_] = type.__orig_class__.__args__[0] if isinstance(type, (EventProp, StructProp)): annotations[name_] |= None if hasattr(obj, "__annotations__"): obj.__annotations__.update(annotations) else: obj.__annotations__ = annotations def autodoc_markdown(app, what, name, obj, options, lines): """Convert all markdown in docstrings to reStructuredText. This includes images and tables. Docstrings are in markdown for VSCode compatibility. """ filtered = [line for line in lines for link in IGNORED_BITLY if link not in line] newlines = m2r2.convert("\n".join(filtered)).splitlines() lines.clear() lines.extend(newlines) def remove_model_signature(app, what, name, obj, options, signature, return_annotation): """Removes the :func:`ModelBase.__init__` args from the docstrings. It's an implementation detail, and only clutters the docs. """ if what == "class" and issubclass(obj, ModelBase): return ("", return_annotation) def remove_enum_signature(app, what, name, obj, options, signature, return_annotation): """Removes erroneous :attr:`signature` = '(value)' for `enum.Enum` subclasses.""" if inspect.isclass(obj) and issubclass(obj, enum.Enum): # Event ID class return ("", return_annotation) def include_obsolete_ids(app, what, name, obj, skip, options): """Includes obsolete / undocumented (prefixed with a `_`) event IDs.""" if isinstance(obj, EventEnum): # EventID member return False def show_model_dunders(app, what, name, obj, skip, options): """Subclasses of ``ModelBase`` show these dunders regardless of any settings.""" if name in ("__getitem__", "__setitem__", "__iter__", "__len__"): return False def setup(app): """Connects all callbacks to their event handlers.""" app.connect("autodoc-process-docstring", badge_flstudio) app.connect("autodoc-process-docstring", autodoc_markdown) app.connect("autodoc-process-signature", add_annotations) app.connect("autodoc-process-signature", remove_model_signature) app.connect("autodoc-process-signature", remove_enum_signature) app.connect("autodoc-skip-member", include_obsolete_ids) app.connect("autodoc-skip-member", show_model_dunders) ================================================ FILE: docs/contributing.rst ================================================ \ :fas:`user-gear` Contributor's Guide ====================================== 🤝 All contributions are welcome. .. important:: PyFLP adheres to the `Contributor Covenant Code of Conduct `_. Please make sure you have read it and accept it before proceeding further. ⬇ The sections below are ordered roughly in the order one would follow. \ :fas:`code-pull-request;sd-text-primary` Making a PR ------------------------------------------------------ .. tip:: Format code with ``black`` PyFLP use the black code style, format your code with it. :fas:`clone` Clone the repo ^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: console git clone https://github.com/demberto/PyFLP :fas:`code-branch` Create a branch ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: console git branch my_new_feature git checkout my_new_feature 🌱 Create a virtual environment ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ I prefer to use `venv `_. .. code-block:: console python -m venv venv This will create a folder named ``venv`` in the current directory. Now, activate the venv: .. code-block:: console ./venv/Scripts/activate 📌 Install dependencies ^^^^^^^^^^^^^^^^^^^^^^^^ Install all dev, user and docs dependencies. .. code-block:: console python -m pip install --upgrade pip pip install -r requirements.txt -r docs/requirements.txt tox |vscode-icon| VS Code integration --------------------------------- I use VS Code for development. I have made certain changes to the workspace to suit my workflows and make life easier. .. todo Inspect whether venv creation can be automated through VSCode. :fab:`python` Python extension configuration ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To ease linting, enforce strict type checking and improve code quality, I have modified certain settings for the official Python / Pylance extension, so that you don't need to manually configure it or encounter issues while committing. Check `settings.json `_. :material-sharp:`extension;1.2em;sd-pb-1` Recommended extensions ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When you open the repo directory in VS Code, you will get recommendations for extensions. I use these extensions myself. You can check `extensions.json `_ to know why and where they are used. :fas:`list-check` Tasks ^^^^^^^^^^^^^^^^^^^^^^^ If you use :fab:`windows;sd-text-secondary` Windows, I have made some shortcuts for common tasks. Check `tasks.json `_. .. |vscode-icon| image:: /img/contributing/vscode.svg :width: 32 |pytest-icon| Testing --------------------- FL Studio comes with a handy feature 🚀 to export "presets" for various :doc:`models <./architecture>` like :class:`Channel`, :class:`Insert` and so on. This is used for **isolating** test results. A look 👀 at `tests/assets directory `_ shows what possible models and properties could be tested from a preset file. I have divided the tests such that they test a model or an individual property. These presets have the same layout of a normal full FLP would use, but only the required events are kept. This *might* and **has** caused some problems while testing properties dependant on data passed from its parent 😔. For instance, an :class:`Insert` gets version from :class:`Mixer` which gets it from :class:`Project` itself. This kind of dependency is not good in my opinion 😐, and I continue to look at ways to improve testing. There also are models which cannot be exported into presets, notable being :class:`Pattern` (although scores can be exported) and the entire :mod:`pyflp.arrangement` module. Currently, I have kept the testing for these in a common FLP. ✴️ Guidelines ^^^^^^^^^^^^^^ 1. Follow the naming scheme of the test functions, it generally follows the format of ``test_{model_collection}`` *or* ``test_{model}_{descriptor}``. 2. Create separate test assets, whenever possible. .. |pytest-icon| image:: /img/contributing/pytest.svg :width: 32 📖 Docs -------- Don't forget to update the `docs `_ after you are done with a feature or a bug fix that affects the documentation. ✴️ Guidelines ^^^^^^^^^^^^^^ 1. ↔ **80 columns** max, wherever possible. Don't consider this for inlined links and tables. .. tip:: Don't start a new sentence at the end of a line. Remember that it should be easily readable to you, first of all. 2. 🌐 **Inline links** if they aren't used twice in the same document. 3. 📝 Should look **clean** enough in a simple text viewer as well. 4. 💡 Use **emojis** if it conveys the meaning of the text next to it. 5. ⚫⚪ Add images for both **light** and **dark** modes. 🛠 Sphinx configuration ^^^^^^^^^^^^^^^^^^^^^^^ Sphinx is the tool I use for generating PyFLP's docs. It comes with a handy plugin called ``sphinx-autodoc`` to automatically generate documentation for the code from Python docstrings. One thing about it, however is that its primarily reStructuredText driven, while my docstrings are all in Github-flavored markdown. Luckily, Sphinx being powerful and extensible provides APIs to modify the docstrings that are sent to the ``sphinx-autodoc`` plugin. Currently, the transformation is divided into these steps (in order): - ⤵ Replacing ``*New in FL Studio ...*`` with shields like these: .. image:: https://img.shields.io/badge/FL%20Studio-20+-5f686d?labelColor=ff7629&style=for-the-badge - ➕ Adding the correct annotations for the :doc:`descriptors <./architecture>`. - ⤵ Converting GFM tables and images in the docstrings to reStructuredText. - ➖ Removing erroneous ``__init__`` method signatures from enums and models. - ➕ Include "private" (obsolete) :class:`pyflp._events.EventEnum` members. - ➕ Include model dunder methods like ``__len__``, ``__iter__`` etc. Check `conf.py `_ for understanding how it is done. 🚧 Still to be documented ^^^^^^^^^^^^^^^^^^^^^^^^^^ .. todolist:: ================================================ FILE: docs/faq.rst ================================================ ❓ FAQ ====== Now I don't frequently get asked any questions, *(I would love to)* but these are some questions I think a newcomer or a contributor might ask? 🗣 How do I get help? ^^^^^^^^^^^^^^^^^^^^^ - Check the [discussions](https://github.com/demberto/PyFLP/discussions), open a new one. - Open an issue if you spot a bug 🐛 or want a new feature ✨. - Email me on demberto(at)proton.me. I will generally get back to you within a day ⏰. ✨ Is "X" supported / implemented? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Pretty much. I have organised PyFLP's code to be pretty self explanatory. If you are completely new to the terminology used by PyFLP, you should also open up FL Studio's documentation open besides the `reference <./reference>`_. If you find something isn't yet implemented, open an issue or, a :doc:`PR <./contributing>` if you have implemented something. ➕➖ Why is there no functionality to **add** / **remove** items from collections? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ *Also answers alternative specific questions like "Why can't I add a channel to the channel rack?", "Why can't I add a new arrangement?"* **Because of version compatibility**. The FLP format is a closed-source format with no documentation. It evolves completely at the whims of Image-Line. I don't work there, nor have a contributor who knows for sure that a particular thing I implement will work for sure. *So*, instead of developing a feature which isn't guaranteed to work, I can better devote my time to support the **modification** of existing properties and items. However, some good news now. I am planning to add support for adding / removing notes from a pattern, adding / deleting automation points. Basically stuff, which hasn't changed a lot since FL Studio first introduced it. 🤝 I want to contribute ^^^^^^^^^^^^^^^^^^^^^^^^ Please check the :doc:`contributor's guide <./contributing>` if you are new to PyFLP. Check the :doc:`architecture` to understand the internals. Also check out the :doc:`developer guides <./guides>`. 🧵 Is PyFLP thread safe? ^^^^^^^^^^^^^^^^^^^^^^^^^ **No.** PyFLP uses ``sortedcontainers``, an awesome library which unfortunately `isn't thread-safe `_. ================================================ FILE: docs/features.rst ================================================ ✨ Features ============ Non-destructive editing ----------------------- The modifications you make will have a minimum effect on the internal structure of an FLP. Infact, I guarantee you that if you save a :class:`pyflp.Project` as-is, the new file will be exactly alike (compare hashes if you want). 📝 Godlevel docstrings ---------------------- PyFLP has been carefully written to take advantage of the features provided by a modern editor, like VS Code. One area, I particularly devoted a lot of time to are docstrings. Since PyFLP's entire documentation is only its reference, I thought it might be challenging for a first time user to know where to find the data they need. :fas:`eye;sd-text-info` Visual hints ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. image:: /img/features/images-dark.png :align: center :class: only-dark .. image:: /img/features/images-light.png :align: center :class: only-light To make it somewhat easier of a journey, I haved added links to images and GIFs from FL Studio's interface. :fas:`table;sd-text-info` Minimums, maximums and defaults ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. image:: /img/features/tables-dark.png :align: center :class: only-dark .. image:: /img/features/tables-light.png :align: center :class: only-light A lot of properties also have *suggested* minimum, maximum and default values. When I say suggested, I mean that FLP is a closed format owned by Image-Line. Its on their whims what they do with it. The values I suggest are only on a *last-I-checked-they-were-these* basis. However, my research till now has shown me that they rarely change. .. important:: For non-VS Code users VS Code uses a rather unstandardised format for parsing docstrings. Unlike PyCharm, it cannot parse rST docstrings. Hence PyCharm users will get a rather bad result from the docstring previews where I have used tables and images, *unfortunately*. I haven't found a way to make docstrings look good while being equally accessible in both PyCharm and VSCode. .. seealso:: `#52 `_ :octicon:`check-circle-fill;1em;sd-text-success` 100% type tested ----------------------------------------------------------------- PyFLP is fully tested with `pyright `_, a static type checker built right into VS Code as well as mypy. :fas:`umbrella;sd-text-primary` 85%+ code coverage -------------------------------------------------- PyFLP boasts a total of more than 85+ combined code coverage across supported Python versions. Higher the coverage ⬆, lesser the amount of bugs 🐞 ================================================ FILE: docs/guides/plugin.rst ================================================ 🚶‍♂️ Walkthrough: Implementing a plugin data parser ==================================================== Implementing a native plugin data parser can be easy. Below is a walkthrough for implementing a simple effect, :class:`Fruity Balance `. .. note:: Prequisites The steps ahead assume that you have an understanding of how binary data types (C's integral types, in this context) work along with a basic understanding of Python itself. 1. Note the parameters exposed by the plugin .. image:: /img/guides/plugin/1-parameters.png :align: center :alt: Fruity Balance paramerers Also take note of the **order** in which they occur. Here its **Balance** followed by **Volume**. 2. Prepare a test FLP Create an empty new FLP, add a **Fruity Balance** to one of the insert slots. .. image:: /img/guides/plugin/2-load-plugin.png :align: center :alt: Fruity Balance in an insert slot Save this FLP as ``fruity-balance.flp``. 3. Getting the plugin data Since this is an **empty** FLP, with no other plugins loaded, you can simply access the plugin data by, .. code-block:: python import pyflp from pyflp.plugin import PluginID # Parse the FLP file into a project project = pyflp.parse("fruity-balance.flp") # Collect all the events as a dict of ID to event events = project.events_asdict() # Get the first plugin data event - the Fruity Balance one itself plugin_data_event = events[PluginID.Data][0] # Get the raw data and convert it to a tuple of 8-bit unsigned integers data = tuple(bytearray(plugin_data_event._struct)) print(data) 4. Observe and analyze the output ▶ You will get an output like this: .. code-block:: python (0, 0, 0, 0, 256, 0, 0, 0) That's a total of **8 bytes** worth of data for **2 knobs**. FL Studio *generally* uses 4 bytes for most type of data, so let's assume each knob takes **4 bytes**. Now compare it with the **positions** of the knobs in Fruity Balance. .. image:: /img/guides/plugin/3-observe-knob-positions.png :align: center :alt: Observe knob positions ‼ Suddenly the data above makes sense. How? Let me explain. - **Balance** knob is at 12 o' clock - **Volume** knob is somewhere at 80% of its maximum. Now convert the 8-bit integer tuple into a two 32-bit integer tuple. We get the values ``0`` and ``256`` respectively. So, now we know, that **Balance** is 0 (because its centred) and **Volume** is at 256. Also, since we didn't modify them at all, these are the **default** values. 🥳 Success! We cracked it! 5. Exercise: Find out the minimum and maximums (optional, but recommended) By rotating the knobs to their extremes and following steps 3-4 again, you can find out the minimum and maximums of each knob. .. hint:: One very important place for finding out the extremes is the hint panel. .. image:: /img/guides/plugin/4-hint-panel.png :align: center :alt: FL Studio hint panel 6. Writing the plugin code ℹ All plugins are implemented in the :mod:`pyflp.plugin` module. .. note:: Take care to follow the naming conventions as shown below. Begin with writing the code for the plugin :ref:`event `: .. code-block:: python class FruityBalanceEvent(StructEventBase): STRUCT = c.Struct("pan" / c.Int32ul, "volume" / c.Int32ul).compile() .. note:: What is ``c.Struct``? PyFLP uses the :mod:`construct` library to define and binary structures. Its a fairly simple to understand declarative binary parser creator. .. tip:: Call :meth:`construct.Struct.compile()` to get a faster version of the "Struct". Check for more information. Now create a :ref:`model ` for the event we just created in the same module: .. code-block:: python class FruityBalance(_PluginBase[FruityBalanceEvent]): pan = _PluginDataProp[int]() volume = _PluginDataProp[int]() You don't need to worry about ``_PluginBase`` and ``_PluginDataProp``. They are implementation-level details, you don't *generally* need to worry about. Derive our newly create ``FruityBalance`` from ``_IPlugin`` and implement it: .. important:: Don't forget to do this. Otherwise the event will not be parsed. .. code-block:: python :emphasize-lines: 1, 2 class FruityBalance(_PluginBase[FruityBalanceEvent], _IPlugin): INTERNAL_NAME = "Fruity Balance" pan = _PluginDataProp[int]() volume = _PluginDataProp[int]() .. note:: Use :doc:`FLPEdit <./reversing>` to find out ``INTERNAL_NAME`` of a plugin. 🎉 And that's basically it. The implementation is complete! Now all we need to do is glue ``FruityBalanceEvent`` and ``FruityBalance`` to the effect slot's :attr:`pyflp.mixer.Slot.plugin` attribute. 7. Glue the implementation to :class:`pyflp.mixer.Slot`: Import our newly created classes in :mod:`pyflp.mixer` and add an entry to :attr:`pyflp.mixer.Slot.plugin` like so: .. code-block:: python :emphasize-lines: 3 plugin = PluginProp( { FruityBalanceEvent: FruityBalance, ... } ) ================================================ FILE: docs/guides/reversing.rst ================================================ 🤓 Reversing FLP format ======================== You should first take a look at :doc:`what events are <../architecture>`. A decent knowledge of the topics mentioned there as well as Python itself is assumed. One could use a hex editor, but its too tedious. I have a simpler solution: .. figure:: /img/guides/reversing/flpedit.png :alt: A test FLP opened in FLPEdit **FLPEdit**, an event view for FLP (and related formats) files. Download it `here `_. This is an unmaintained software, written actually in C#. Event ID names are different but the file attached above has source code as well. Check the ``FLPFileFormat/FLP_File.cs`` file for a list of event IDs and compare them to the ones from :class:`pyflp._events.EventEnum` in PyFLP. Events ------ Which event needs to be inspected can only be understood when you observe the ordering of events, whether they occur for default values or not as well as a general knowledge of new features and changes occuring inside FL Studio. Check `this discussion `_ for a list of unknown / undiscovered events. Struct fields ------------- Structs whose field names are prefixed with a ``_u`` are undiscovered fields. Wherever possible, I have added helpful comments right next to them. Also, throughout PyFLP's codebase, there are a number of ``# TODO`` comments. Some of these can have additional information about them. My workflow ----------- 1. Create a new test FLP or a preset and save it. 2. Parse the file with PyFLP and record the initial values. 3. Turn knobs / faders all the way to their extremes, save and repeat (2) .. hint:: WhatsNew.rtf FL Studio's changelog file ``WhatsNew.rtf`` exists in its install folder. It is a very helpful source for understanding which features were added when. ================================================ FILE: docs/guides.rst ================================================ 📖 Developer guides ==================== Want to be a **contributor**? Interested in the internals of the FLP format? This is the perfect place to begin. Reading :doc:`architecture` is also recommended but not necessarily required. .. toctree:: :glob: guides/* ================================================ FILE: docs/handbook.rst ================================================ 📚 Handbook ============ This page contains some ideas on how one can use PyFLP for automating tasks (*to a certain extent*) which can only be done via FL Studio. A basic-to-intermediate level of Python knowledge is assumed. No prior knowledge of PyFLP is required for any of the sections below. *These all are written from a dev's POV. I would ♥ to get more ideas and hear about different use cases.* 📦 Exporting to a ZIP ---------------------- Imagine you had a folder structure like this: .. code-block:: 📁 Samples ├─── 🥁 kick.wav ├─── 👏 clap.wav └─── 🎵 toms.wav 📄 MyGreatSong.flp For the purpose of simplicity, assume that ``📄 MyGreatSong.flp`` uses only the samples from ``📁 Samples`` and all **sample file names are unique**. The code below will create a ZIP containing all the samples used .. code-block:: python from zipfile import ZipFile import pyflp project = pyflp.parse("MyGreatSong.flp") with ZipFile("MyGreatSong.zip", "x") as zp: zp.write("MyGreatSong.flp") for sampler in project.channels.samplers: if sampler.sample_path is not None: zp.write(sampler.sample_path) .. caution:: Missing samples The above code assumes that all the samples exist at the paths the FLP has stored. If any of the samples aren't found, there will be an error. FL Studio doesn't give up this easily. It searches up a lot of paths, including but not limited to the recursive scanning of folders which are: - Current directory. - Added to the sample browser. - Containing previous samples / missing samples. This will create a ZIP file of the structure: .. code-block:: 📦 MyGreatSong.zip ├─── 📄 MyGreatSong.flp ├─── 🥁 kick.wav ├─── 👏 clap.wav └─── 🎵 toms.wav .. hint:: FL Studio stock samples While this will work for 3rd party samples *unless there's 2 samples with the same name*, FL Studio doesn't store the full path inside an FLP for stock samples. See :attr:`pyflp.channel.Sampler.sample_path` for more info. 🔓 Unlocking demo version FLPs ------------------------------- .. caution:: This doesn't work for FL Studio 21 projects. See `#146 ` FLPs saved with a trial version of FL Studio cannot be reopened again without saving in a registered version. The state of demo versions of native plugins' is not retained either. .. hint:: This section **doesn't** explain how to make 3rd party plugin demos recall their state. They have their own mechanisms for doing that. It is possible to undo both of these: .. seealso:: :attr:`Project.licensed ` and :attr:`_PluginBase.demo_mode `. .. code-block:: python import pyflp project = pyflp.parse("/path/to/myflp.flp") # Unlock the FLP itself project.licensed = True # Unlock trial version native plugins for instument in project.channels.instruments: instrument.plugin.demo_mode = False for insert in project.mixer: for slot in insert: if slot.plugin is not None: slot.plugin.demo_mode = False pyflp.save(project, "/path/to/myflp_unlocked.flp") .. note:: An unregistered version of FL Studio will roll back these changes once you save an FLP in it (even previously registered ones), so you need to repeat this process everytime. ================================================ FILE: docs/helping.rst ================================================ 🙌 Helping PyFLP ================= PyFLP is completely free and open source (FOSS) software. It takes a lot of time and efforts to maintain it and keep improving it. I try to help anyone having any issues or anyone who wants to contribute in any way possible to the best of my efforts. I don't ask for donations or any sort of funding. If you like PyFLP and want it to grow and improve, you can do the following things: ⭐ Star **PyFLP** on Github ---------------------------- .. image:: /img/helping/star-repo-dark.gif :align: center :class: only-dark :target: https://github.com/demberto/PyFLP :alt: ⭐ How to star PyFLP? .. image:: /img/helping/star-repo-light.gif :align: center :class: only-light :target: https://github.com/demberto/PyFLP :alt: ⭐ How to star PyFLP? You can "star" the repo if you have a Github account. It is analogous to "following" on social media and helps :abbr:`SEO (Search engine optimization)`. 👀 Watch **PyFLP** on Github ----------------------------- .. image:: /img/helping/watch-repo-dark.gif :align: center :class: only-dark :target: https://github.com/demberto/PyFLP :alt: 👀 How to watch PyFLP? .. image:: /img/helping/watch-repo-light.gif :align: center :class: only-light :target: https://github.com/demberto/PyFLP :alt: 👀 How to watch PyFLP? You can "watch" the repo if you have a Github account. It will notify you about all changes taking places in PyFLP right in your 📨 email. 🐞 Reporting bugs ------------------ .. image:: /img/helping/open-issue-dark.png :align: center :class: only-dark :target: https://github.com/demberto/PyFLP :alt: 🐞 How to open an issue? .. image:: /img/helping/open-issue-light.png :align: center :class: only-light :target: https://github.com/demberto/PyFLP :alt: 🐞 How to open an issue? If you find out that something isn't quite working as its supposed to, please open an issue `here `_ and follow the instructions provided in the template to fill out a bug report. 🔎 Check out the **Discussions** --------------------------------- 🗣 `Discussions `_ is the place where I announce what's coming new, when its coming and a few other topics related to the FLP format. ❗ If you have a taste in reverse engineering or binary formats, you must most definitely check it out. 🙌 You can also open a new discussion to tell me what you made with PyFLP and how it helped you. I am more than glad to find how PyFLP is getting used. Help the tools that power **PyFLP** ----------------------------------- - `construct `_ - `f-enum `_ - `sortedcontainers `_ ================================================ FILE: docs/index.rst ================================================ .. mdinclude:: ../README.md Navigation ---------- .. toctree:: :maxdepth: 2 :titlesonly: handbook reference features architecture contributing guides faq limitations helping ⏰ Changelog .. sidebar-links:: :github: :pypi: PyFLP Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: docs/limitations.rst ================================================ 🚫 Limitations =============== Before you begin reading, I would like to **emphasize** that FLP is a closed and undocumented format. The knowledge needed for understanding the internals is published nowhere, except for a few notes lying around here and there and some existing implementations which I deeply thank for saving my time. Whatever PyFLP does, is on a best-effort level. Things can go wrong so its always wise to have **backups** and *avoid* **overwriting**. Most properties are discovered; their representations aren't ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You will find almost all the properties you could imagine. However FLP being a binary format stores any and all kinds of stuff as integers. Its actually harder to calculate the formula used for representing stuff like frequency, volume and other such non-linear stuff. Another thing is musical timings, check :github:issue:`75` for a more info. Items cannot be added or removed, only modified ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ I am working on making it possible to add or remove items (like channels, patterns, MIDI notes, arrangements etc.) currently. Certain things like MIDI notes are simpler to add, but things like patterns and channels will be harder to get correct at. For those things, a **clone** like operation is easier to implement. .. note:: It is possible, however (as of PyFLP 2.0.0a4) to add or remove events. If you don't know what they are, you probably shouldn't be handling them. If you are confident about working with events directly, you can very much add new events to effectively do things like adding your own items. Why is it *slow*? ^^^^^^^^^^^^^^^^^ Slow is a relative term - to some it might not be noticeable at all. Although in my opinion, PyFLP has become way slower since I migrated to ``construct``, which provides a lot more benefits than what I did earlier. ``construct`` has an opt-in compilation feature which although isn't usable for all kinds of structs, is available for most of them, which gived quite a speed boost for structs that occur a lot (MIDI notes, playlist items to name a few.) * Due to PyFLP's lazily evaluated nature, most delays don't occur upfront i.e during :meth:`pyflp.parse`. * Python enums are quite slow, to the point that adding the ``f-enum`` library patch, reduced parse time by 50%. * :class:`pyflp._events.EventTree` class' need for ``sortedcontainers.SortedList`` which is implemented in pure Python. Difficult to make ports ^^^^^^^^^^^^^^^^^^^^^^^ The current working of PyFLP is non-replicable in most other languages. Descriptors are a Python specific feature I have yet to find anywhere else. Therefore, the possibility of a port that's as clean (and featured) as PyFLP is less. Most languages however have some sort of 3rd party Python interop library available, so its not like PyFLP is completely unuseable from other languages. A quick search on Github will return some FLP parsers available for other languages, but almost all of them are pretty much unmaintained or archived. Unit-testing is paramount ^^^^^^^^^^^^^^^^^^^^^^^^^ Due to the lazy nature of models and their descriptors, each of them should be tested so as to ensure that no changes in the event handling affect or break. For a long time, I used only a single FLP to test all of PyFLP's API. Things have changed now and I use presets exported from FL Studio itself for the testing of a huge chunk of API to ensure isolation of test results. The problem is that all the test data comes from FL Studio itself and can be only really validated in the same. That's the reason I usually don't raise any errors event if I know quite surely that, for example a value out of range is set for some property. ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd ================================================ FILE: docs/reference/arrangement/arrangement.rst ================================================ \ :fas:`trowel-bricks` Arrangement ================================== .. currentmodule:: pyflp.arrangement .. autoclass:: Arrangement :members: .. autoclass:: TimeSignature :members: .. autoclass:: ArrangementID :members: :member-order: bysource ================================================ FILE: docs/reference/arrangement/index.rst ================================================ Arrangements ============ .. module:: pyflp.arrangement .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: * .. autoclass:: Arrangements :members: .. autoclass:: ArrangementsID :members: :member-order: bysource ================================================ FILE: docs/reference/arrangement/playlist.rst ================================================ \ :material-sharp:`playlist_play;1.2em;sd-pb-1` Playlist ======================================================== .. currentmodule:: pyflp.arrangement .. autoclass:: PLItemBase :members: .. autoclass:: ChannelPLItem :members: :show-inheritance: PLItemBase .. autoclass:: PatternPLItem :members: :show-inheritance: PLItemBase ================================================ FILE: docs/reference/arrangement/track.rst ================================================ Track ===== .. currentmodule:: pyflp.arrangement .. autoclass:: Track :members: .. grid:: .. grid-item:: .. autoclass:: TrackMotion :members: .. grid-item:: :child-align: center :columns: auto .. image:: /img/arrangement/track/motion.png .. grid:: .. grid-item:: .. autoclass:: TrackPress :members: .. grid-item:: :child-align: center :columns: auto .. image:: /img/arrangement/track/press.png .. grid:: .. grid-item:: .. autoclass:: TrackSync :members: .. grid-item:: :child-align: center :columns: auto .. image:: /img/arrangement/track/sync.png .. autoclass:: TrackID :members: :member-order: bysource ================================================ FILE: docs/reference/channel/automation.rst ================================================ \ :fas:`bezier-curve` Automation ================================ .. currentmodule:: pyflp.channel .. autoclass:: Automation :show-inheritance: :members: .. autoclass:: AutomationLFO :members: .. autoclass:: AutomationPoint :members: ================================================ FILE: docs/reference/channel/channel.rst ================================================ Channel ======= .. currentmodule:: pyflp.channel .. autoclass:: Channel :members: Enums ----- .. autoclass:: ChannelType :members: .. autoclass:: ChannelID :members: :member-order: bysource ================================================ FILE: docs/reference/channel/display-group.rst ================================================ DisplayGroup ============ .. currentmodule:: pyflp.channel .. autoclass:: DisplayGroup :members: .. autoclass:: DisplayGroupID :members: :member-order: bysource ================================================ FILE: docs/reference/channel/index.rst ================================================ \ :material-sharp:`dns;1.2em;sd-pb-1` Channel Rack ================================================== .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: * .. module:: pyflp.channel .. autoclass:: ChannelRack :members: .. autoclass:: RackID :members: :member-order: bysource .. autoexception:: ChannelNotFound ================================================ FILE: docs/reference/channel/instrument.rst ================================================ Instrument ========== .. currentmodule:: pyflp.channel .. autoclass:: Instrument :show-inheritance: :members: :inherited-members: Channel ================================================ FILE: docs/reference/channel/layer.rst ================================================ \ :fas:`layer-group` Layer ========================== .. currentmodule:: pyflp.channel .. autoclass:: Layer :show-inheritance: :members: ================================================ FILE: docs/reference/channel/sampler.rst ================================================ \ :material-sharp:`audio_file;1.2em;sd-pb-1` Sampler ==================================================== .. currentmodule:: pyflp.channel .. autoclass:: Sampler :show-inheritance: :members: :inherited-members: Channel .. autoclass:: Content :members: .. autoclass:: Envelope :members: .. autoclass:: Filter :members: .. autoclass:: FX :members: .. autoclass:: Playback :members: .. autoclass:: Reverb :members: .. autoclass:: SamplerLFO :members: .. autoclass:: TimeStretching :members: Enums ----- .. autoclass:: DeclickMode :members: .. autoclass:: LFOShape :members: .. autoclass:: ReverbType :members: .. grid:: .. grid-item:: .. autoclass:: StretchMode :members: .. grid-item:: :child-align: center :columns: auto .. image:: /img/channel/stretch-mode.png ================================================ FILE: docs/reference/channel/shared.rst ================================================ Shared ====== .. currentmodule:: pyflp.channel These implement functionality used by :class:`Channel` or its subclasses. .. autoclass:: Arp :members: .. autoclass:: Delay :members: .. autoclass:: Keyboard :members: .. autoclass:: LevelAdjusts :members: .. autoclass:: Polyphony :members: .. autoclass:: Time :members: .. autoclass:: Tracking :members: Enums ----- .. autoclass:: ArpDirection :members: ================================================ FILE: docs/reference/controllers.rst ================================================ 🎛 Controllers ============= .. module:: pyflp.controller .. autoclass:: RemoteController :members: Enums ----- .. autoclass:: ControllerID :members: :member-order: bysource ================================================ FILE: docs/reference/events.rst ================================================ \ :fas:`ellipsis` Events ======================== This section is intended for those who want to delve into PyFLP's low-level API or understand how internally events are ordered. A good understanding of FL Studio's GUI is assumed. When to use the low level API? ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If PyFLP fails to parse a particular model or you want to dive deep into the true / raw / real (whatever you want to call it) representation of an FLP. .. seealso:: :ref:`Binary layout ` of an event Structure --------- Very early versions of FL Studio were literally a **dump** of the changes taking place in FL's GUI. Say for example, you create a channel and *then* add notes to some pattern; the events for those would be dumped in the same order. Hopefully its not the same now, but some of those characteristics are still visible. .. caution:: DO NOT use the following section as a definitive / complete source of information for adding / removing your own events. While *most likely* your events will get parsed correctly, there's always a chance of corrupting your FLPs. This is *roughly* the order of the events (as of latest FL Studio): 1. Project-wide / metadata 2. Display groups / channel filters 3. Initialised controls 4. Pattern notes / controllers 5. MIDI remote controllers 6. Internal remote controllers / automations 7. 1st channel 8. Pattern metadata 9. Remaining channels 10. Arrangements: a. Index, name b. Playlist items c. Time markers d. Tracks: I. All data except name II. Name 11. Mixer: a. Inserts: A list of events in the order: I. Color, name, icon and flags II. Effect slots III. Post EQ, input/output, routing b. Remaining insert data 12. Channel rack height Channel ^^^^^^^ There are currently 5 types of channels (specified in :class:`ChannelType`). Although some of them don't use certain events, FL Studio dumps the same event tree for any type of channel. For e.g. a :class:`Layer` channel will have all the events a :class:`Sampler` channel has, irrespective of whether the events have any meaning in that context. Certain channels have extra events. === =================================== ================================================ # Event ID Model / property === =================================== ================================================ 1 :attr:`ChannelID.New` :attr:`Channel.iid` 2 :attr:`ChannelID.Type` :class:`Channel` subclasses 3 :attr:`PluginID.InternalName` :attr:`Channel.internal_name` 4 :attr:`PluginID.Wrapper` :attr:`Instrument.plugin` 5 :attr:`PluginID.Name` :attr:`Channel.name` 6 :attr:`PluginID.Icon` :attr:`Channel.icon` 7 :attr:`PluginID.Color` :attr:`Channel.color` 8 :attr:`PluginID.Data` :attr:`Instrument.plugin` 9 :attr:`ChannelID.IsEnabled` :attr:`Channel.enabled` 10 :attr:`ChannelID.Delay` :attr:`_SamplerInstrument.delay` [#1]_ 11 :attr:`ChannelID.DelayModXY` :attr:`_SamplerInstrument.delay` [#1]_ 12 :attr:`ChannelID.Reverb` :attr:`Sampler.fx.reverb` 13 :attr:`ChannelID.TimeShift` :attr:`_SamplerInstrument.time.shift` [#1] 14 :attr:`ChannelID.Swing` :attr:`_SamplerInstrument.time.swing` [#1]_ 15 :attr:`ChannelID.FreqTilt` :attr:`Sampler.fx.freq_tilt` 16 :attr:`ChannelID.Pogo` :attr:`Sampler.fx.pogo` 17 :attr:`ChannelID.Cutoff` :attr:`Sampler.fx.cutoff` 18 :attr:`ChannelID.Resonance` :attr:`Sampler.fx.reso` 19 :attr:`ChannelID.Preamp` :attr:`Sampler.fx.boost` 20 :attr:`ChannelID.FadeOut` :attr:`Sampler.fx.fade_out` 21 :attr:`ChannelID.FadeIn` :attr:`Sampler.fx.fade_in` 22 :attr:`ChannelID.StereoDelay` :attr:`Sampler.fx.stereo_delay` 23 :attr:`ChannelID.RingMod` :attr:`Sampler.fx.ringmod` 24 :attr:`ChannelID.FXFlags` Quite a few, refer code. 25 :attr:`ChannelID.RoutedTo` :attr:`_SamplerInstrument.insert` [#1]_ 26 :attr:`ChannelID.Levels` :attr:`Sampler.filter` + few more 27 :attr:`ChannelID.LevelAdjusts` :attr:`_SamplerInstrument.level_adjusts` [#1]_ 28 :attr:`ChannelID.Polyphony` :attr:`_SamplerInstrument.polyphony` [#1]_ 29 :attr:`ChannelID.Parameters` A lot; spread across many models. 30 :attr:`ChannelID.CutGroup` :attr:`_SamplerInstrument.cut_group` [#1]_ 31 :attr:`ChannelID.LayerFlags` :attr:`Layer.random`, :attr:`Layer.crossfade` 32 :attr:`ChannelID.GroupNum` :attr:`Channel.group` 33* :attr:`ChannelID.Automation` :class:`Automation` 34 :attr:`ChannelID.IsLocked` :attr:`Channel.locked` 35 :attr:`ChannelID.Tracking` * 2 :attr:`_SamplerInstrument.tracking` [#1]_ 37 :attr:`ChannelID.EnvelopeLFO` * 5 :attr:`Sampler.envelopes`, :attr:`Sampler.lfos` 42 :attr:`ChannelID.SamplerFlags` Certain :class:`Sampler` properties. 43 :attr:`ChannelID.PingPongLoop` :attr:`Sampler.playback.ping_pong_loop` 44* :attr:`ChannelID.SamplePath` :attr:`Sampler.sample_path` [#2]_ === =================================== ================================================ .. [#1] :class:`Sampler` & :class:`Instrument` base off of :class:`_SamplerInstrument`. .. [#2] Optional event for :class:`Sampler` only. Pattern ^^^^^^^ :class:`Pattern` events are serialised at 2 different places inside an FLP. The first section contains the notes and controllers held by a pattern if any. = ============================= =========================== # Event ID Property = ============================= =========================== 1 :attr:`PatternID.New` :attr:`Pattern.iid` 2 :attr:`PatternID.Controllers` :attr:`Pattern.controllers` 3 :attr:`PatternID.Notes` :attr:`Pattern.notes` = ============================= =========================== The next section contains colour, icon, timemarkers and any new events get added here. Some events aren't listed because their order is not confirmed yet. = ============================= ====================== # Event ID Property = ============================= ====================== 1 :attr:`PatternID.New` [#3]_ :attr:`Pattern.iid` 2 :attr:`PatternID.Name` :attr:`Pattern.name` 3 :attr:`PatternID.Color` :attr:`Pattern.color` 4 157 [#3]_ N.A. 5 158 [#3]_ N.A 6 164 [#3]_ N.A. = ============================= ====================== .. [#3] Acts as an identifier here. .. [#4] Unknown events; complete list `here `_. VST plugin parsing ^^^^^^^^^^^^^^^^^^ Implemented in :class:`VSTPluginEvent`, this is arguably the hardest event to parse *cleanly*. If you are familiar with PyFLP's internals, you might be surprised to know that this event has events *inside events*. Why a struct wasn't usable is beyond me. ================================================ FILE: docs/reference/exceptions.rst ================================================ 🛑 Exceptions ============== .. automodule:: pyflp.exceptions :members: :show-inheritance: :undoc-members: ================================================ FILE: docs/reference/mixer/index.rst ================================================ \ :material-sharp:`settings_input_component;1.2em;sd-pb-1` Mixer ================================================================ .. module:: pyflp.mixer .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: * .. autoclass:: Mixer :members: Enums ----- .. autoclass:: MixerID :members: :member-order: bysource ================================================ FILE: docs/reference/mixer/insert.rst ================================================ \ :fas:`sliders` Insert ======================= .. currentmodule:: pyflp.mixer .. autoclass:: Insert :members: .. autoclass:: InsertEQ :members: .. autoclass:: InsertEQBand :members: Enums ----- .. grid:: auto .. grid-item:: .. autoclass:: InsertDock :members: .. grid-item:: :child-align: center .. image:: /img/mixer/insert/dock.png .. autoclass:: InsertID :members: :member-order: bysource ================================================ FILE: docs/reference/mixer/slot.rst ================================================ \ :fas:`folder-tree` Slot ========================= .. currentmodule:: pyflp.mixer .. autoclass:: Slot :members: Enums ----- .. autoclass:: SlotID :members: :member-order: bysource ================================================ FILE: docs/reference/patterns/index.rst ================================================ 🎹 Patterns ============ .. module:: pyflp.pattern .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: * .. autoclass:: Patterns :members: Enums ----- .. autoclass:: PatternsID :members: :member-order: bysource ================================================ FILE: docs/reference/patterns/pattern.rst ================================================ Pattern ======= .. currentmodule:: pyflp.pattern .. autoclass:: Pattern :members: .. autoclass:: Controller :members: .. autoclass:: Note :members: Enums ----- .. autoclass:: PatternID :members: :member-order: bysource ================================================ FILE: docs/reference/plugins/effects.rst ================================================ Effects ======= .. currentmodule:: pyflp.plugin .. autoclass:: FruityBalance :members: .. autoclass:: FruityBloodOverdrive :members: .. autoclass:: FruityCenter :members: .. autoclass:: FruityFastDist :members: .. autoclass:: FruityNotebook2 :members: .. autoclass:: FruitySend :members: .. autoclass:: FruitySoftClipper :members: .. autoclass:: FruityStereoEnhancer :members: .. autoclass:: Soundgoodizer :members: ================================================ FILE: docs/reference/plugins/generators.rst ================================================ Generators ========== .. currentmodule:: pyflp.plugin .. autoclass:: BooBass :members: ================================================ FILE: docs/reference/plugins/index.rst ================================================ \ :material-sharp:`extension;1.2em;sd-pb-1` Plugins =================================================== .. module:: pyflp.plugin .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: * .. autoclass:: _PluginBase :members: .. autoclass:: PluginIOInfo :members: Enums ----- .. autoclass:: WrapperPage :members: .. autoclass:: PluginID :members: :member-order: bysource ================================================ FILE: docs/reference/plugins/vst.rst ================================================ VST === .. currentmodule:: pyflp.plugin .. autoclass:: VSTPlugin :members: .. tab-set:: .. tab-item:: Settings .. image:: /img/plugin/wrapper/settings.png .. autoclass:: pyflp.plugin::VSTPlugin._AutomationOptions :members: .. autoclass:: pyflp.plugin::VSTPlugin._MIDIOptions :members: .. autoclass:: pyflp.plugin::VSTPlugin._UIOptions :members: .. tab-item:: Processing .. image:: /img/plugin/wrapper/processing.png .. autoclass:: pyflp.plugin::VSTPlugin._ProcessingOptions :members: .. tab-item:: Troubleshooting .. image:: /img/plugin/wrapper/troubleshooting.png .. autoclass:: pyflp.plugin::VSTPlugin._CompatibilityOptions :members: ================================================ FILE: docs/reference/project.rst ================================================ \ :fas:`file-waveform` Project ============================== .. module:: pyflp.project .. autoclass:: Project :members: .. dropdown:: Information page :open: .. grid:: .. grid-item:: :columns: auto * :attr:`Project.artists` * :attr:`Project.created_on` * :attr:`Project.comments` * :attr:`Project.genre` * :attr:`Project.show_info` * :attr:`Project.url` * :attr:`Project.time_spent` .. grid-item:: :columns: 12 8 8 8 :margin: auto .. image:: /img/project/info.png .. dropdown:: Settings page :open: .. grid:: .. grid-item:: * :attr:`Project.data_path` * :attr:`Project.pan_law` * :attr:`Project.ppq` * :attr:`Arrangements.time_signature ` * :attr:`Patterns.play_cut_notes ` .. grid-item:: .. image:: /img/project/settings.png :align: right Enums ----- .. autoclass:: FileFormat :members: :member-order: bysource .. autoclass:: PanLaw :members: :member-order: bysource .. autoclass:: ProjectID :members: :member-order: bysource ================================================ FILE: docs/reference/timemarkers.rst ================================================ \ :fas:`timeline` Timemarkers ============================= .. module:: pyflp.timemarker .. autoclass:: TimeMarker :members: Enums ----- .. grid:: .. grid-item:: .. autoclass:: TimeMarkerType :members: .. grid-item:: :child-align: center :columns: auto .. image:: /img/arrangement/timemarker/action.png .. autoclass:: TimeMarkerID :members: :member-order: bysource ================================================ FILE: docs/reference.rst ================================================ 🧾 Reference ============= .. toctree:: :maxdepth: 2 :titlesonly: :caption: Contents: :glob: reference/*/index reference/* :material-outlined:`api` API ---------------------------- PyFLP provides a low-level events-based API and a high-level API. Generally, you should only need the high level API though. .. module:: pyflp .. autofunction:: parse .. autofunction:: save ================================================ FILE: docs/requirements.txt ================================================ furo==2023.5.20 m2r2==0.3.2 # https://github.com/CrossNox/m2r2/issues/55 sphinx==6.1.3 sphinx-copybutton==0.5.2 sphinx-design==0.4.1 sphinx-hoverxref sphinx-toolbox==3.4.0 sphinxcontrib-spelling==8.0.0 sphinxcontrib-svgbob==0.2.1 ================================================ FILE: pyflp/__init__.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """ PyFLP - FL Studio project file parser ===================================== Load a project file: >>> import pyflp >>> project = pyflp.parse("/path/to/parse.flp") Save the project: >>> pyflp.save(project, "/path/to/save.flp") Full docs are available at https://pyflp.rtfd.io. """ # noqa from __future__ import annotations import io import os import pathlib import struct import sys import construct as c from pyflp._events import ( DATA, DWORD, NEW_TEXT_IDS, TEXT, WORD, AnyEvent, AsciiEvent, EventEnum, EventTree, IndexedEvent, U8Event, U16Event, U32Event, UnicodeEvent, UnknownDataEvent, ) from pyflp.exceptions import HeaderCorrupted, VersionNotDetected from pyflp.plugin import PluginID, get_event_by_internal_name from pyflp.project import VALID_PPQS, FileFormat, Project, ProjectID __all__ = ["parse", "save"] FLP_HEADER = struct.Struct("4sIh2H") if sys.version_info < (3, 11): # https://github.com/Bobronium/fastenum/issues/2 import fastenum fastenum.enable() # 33% faster parse() def parse(file: pathlib.Path | str) -> Project: """Parses an FL Studio project file and returns a parsed :class:`Project`. Args: file: Path to the FLP. Raises: HeaderCorrupted: When an invalid value is found in the file header. VersionNotDetected: A correct string type couldn't be determined. """ with open(file, "rb") as flp: stream = io.BytesIO(flp.read()) events: list[AnyEvent] = [] header = stream.read(FLP_HEADER.size) try: hdr_magic, hdr_size, fmt, channel_count, ppq = FLP_HEADER.unpack(header) except struct.error as exc: raise HeaderCorrupted("Couldn't read the header entirely") from exc if hdr_magic != b"FLhd": raise HeaderCorrupted("Unexpected header chunk magic; expected 'FLhd'") if hdr_size != 6: raise HeaderCorrupted("Unexpected header chunk size; expected 6") try: file_format = FileFormat(fmt) except ValueError as exc: raise HeaderCorrupted("Unsupported project file format") from exc if ppq not in VALID_PPQS: raise HeaderCorrupted("Invalid PPQ") if stream.read(4) != b"FLdt": raise HeaderCorrupted("Unexpected data chunk magic; expected 'FLdt'") events_size = int.from_bytes(stream.read(4), "little") if not events_size: # pragma: no cover raise HeaderCorrupted("Data chunk size couldn't be read") stream.seek(0, os.SEEK_END) file_size = stream.tell() if file_size != events_size + 22: raise HeaderCorrupted("Data chunk size corrupted") plug_name = None str_type: type[AsciiEvent] | type[UnicodeEvent] | None = None stream.seek(22) # Back to start of events while stream.tell() < file_size: event_type: type[AnyEvent] | None = None id = EventEnum(int.from_bytes(stream.read(1), "little")) if id < WORD: value = stream.read(1) elif id < DWORD: value = stream.read(2) elif id < TEXT: value = stream.read(4) else: size = c.VarInt.parse_stream(stream) value = stream.read(size) if id == ProjectID.FLVersion: parts = value.decode("ascii").rstrip("\0").split(".") if [int(part) for part in parts][0:2] >= [11, 5]: str_type = UnicodeEvent else: str_type = AsciiEvent for enum_ in EventEnum.__subclasses__(): if id in enum_: event_type = getattr(enum_(id), "type") break if event_type is None: if id < WORD: event_type = U8Event elif id < DWORD: event_type = U16Event elif id < TEXT: event_type = U32Event elif id < DATA or id.value in NEW_TEXT_IDS: if str_type is None: # pragma: no cover raise VersionNotDetected # ! This should never happen event_type = str_type if id == PluginID.InternalName: plug_name = event_type(id, value).value elif id == PluginID.Data and plug_name is not None: event_type = get_event_by_internal_name(plug_name) else: event_type = UnknownDataEvent events.append(event_type(id, value)) return Project( EventTree(init=(IndexedEvent(r, e) for r, e in enumerate(events))), channel_count=channel_count, format=file_format, ppq=ppq, ) def save(project: Project, file: pathlib.Path | str) -> None: """Save a parsed project back into a file. Caution: Always have a backup ready, just in case 😉 Args: project: The object returned by :meth:`parse`. file: The file in which the contents of :attr:`project` are serialised back. """ buf = bytearray() num_channels = len(project.channels) header = FLP_HEADER.pack(b"FLhd", 6, project.format, num_channels, project.ppq) buf.extend(header) buf.extend(b"FLdt" + (b"\0" * 4)) total_size = 0 for event in project.events: raw = bytes(event) total_size += len(raw) buf.extend(raw) buf[18:22] = total_size.to_bytes(4, "little") with open(file, "wb") as fp: fp.write(buf) ================================================ FILE: pyflp/_adapters.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . from __future__ import annotations import math import warnings from typing import Any, List, Tuple import construct as c import construct_typed as ct from typing_extensions import TypeAlias from pyflp.types import ET, MusicalTime, T, U SimpleAdapter: TypeAlias = ct.Adapter[T, T, U, U] """Duplicates type parameters for `construct.Adapter`.""" FourByteBool: c.ExprAdapter[int, int, bool, int] = c.ExprAdapter( c.Int32ul, lambda obj_, *_: bool(obj_), lambda obj_, *_: int(obj_) # type: ignore ) class List2Tuple(SimpleAdapter[Any, Tuple[int, int]]): def _decode(self, obj: c.ListContainer[int], *_: Any) -> tuple[int, int]: _1, _2 = tuple(obj) return _1, _2 def _encode(self, obj: tuple[int, int], *_: Any) -> c.ListContainer[int]: return c.ListContainer([*obj]) class LinearMusical(SimpleAdapter[int, MusicalTime]): def _encode(self, obj: MusicalTime, *_: Any) -> int: if obj.ticks % 5: warnings.warn("Ticks must be a multiple of 5", UserWarning) return (obj.bars * 768) + (obj.beats * 48) + int(obj.ticks * 0.2) def _decode(self, obj: int, *_: Any) -> MusicalTime: bars, remainder = divmod(obj, 768) beats, remainder = divmod(remainder, 48) return MusicalTime(bars, beats, ticks=remainder * 5) class Log2(SimpleAdapter[int, float]): def __init__(self, subcon: Any, factor: int) -> None: super().__init__(subcon) # type: ignore[call-arg] self.factor = factor def _encode(self, obj: float, *_: Any) -> int: return int(self.factor * math.log2(obj)) def _decode(self, obj: int, *_: Any) -> float: return 2 ** (obj / self.factor) # Thanks to @algmyr from Python Discord server for finding out the formulae used # ! See https://github.com/construct/construct/issues/999 class LogNormal(SimpleAdapter[List[int], float]): def __init__(self, subcon: Any, bound: tuple[int, int]) -> None: super().__init__(subcon) # type: ignore[call-arg] self.lo, self.hi = bound def _encode(self, obj: float, *_: Any) -> list[int]: """Clamps the integer representation of ``obj`` and returns it.""" if not 0.0 <= obj <= 1.0: raise ValueError(f"Expected a value between 0.0 to 1.0; got {obj}") if not obj: # log2(0.0) --> -inf ==> 0 return [0, 0] return [min(max(self.lo, int(2**12 * (math.log2(obj) + 15))), self.hi), 63] def _decode(self, obj: list[int], *_: Any) -> float: """Returns a float representation of ``obj[0]`` between 0.0 to 1.0.""" if not obj[0]: return 0.0 if obj[1] != 63: raise ValueError(f"Not a LogNormal, 2nd int must be 63; not {obj[1]}") return max(min(1.0, 2 ** (obj[0] / 2**12) / 2**15), 0.0) class StdEnum(SimpleAdapter[int, ET]): def _encode(self, obj: ET, *_: Any) -> int: return obj.value def _decode(self, obj: int, *_: Any) -> ET: return self.__orig_class__.__args__[0](obj) # type: ignore ================================================ FILE: pyflp/_descriptors.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the descriptor and adaptor classes used by models and events.""" from __future__ import annotations import abc import enum from typing import Any, Protocol, overload, runtime_checkable from typing_extensions import Self, final from pyflp._events import AnyEvent, EventEnum, StructEventBase from pyflp._models import VE, EMT_co, EventModel, ItemModel, ModelBase from pyflp.exceptions import PropertyCannotBeSet from pyflp.types import T, T_co @runtime_checkable class ROProperty(Protocol[T_co]): """Protocol for a read-only descriptor.""" def __get__(self, ins: Any, owner: Any = None) -> T_co | Self | None: ... @runtime_checkable class RWProperty(ROProperty[T], Protocol): """Protocol for a read-write descriptor.""" def __set__(self, ins: Any, value: T) -> None: ... class NamedPropMixin: def __init__(self, prop: str | None = None) -> None: self._prop = prop or "" def __set_name__(self, _: Any, name: str) -> None: if not self._prop: self._prop = name class PropBase(abc.ABC, RWProperty[T]): def __init__(self, *ids: EventEnum, default: T | None = None, readonly: bool = False): self._ids = ids self._default = default self._readonly = readonly @overload def _get_event(self, ins: ItemModel[VE]) -> ItemModel[VE]: ... @overload def _get_event(self, ins: EventModel) -> AnyEvent | None: ... def _get_event(self, ins: ItemModel[VE] | EventModel): if isinstance(ins, ItemModel): return ins if not self._ids: if len(ins.events) > 1: # Prevent ambiguous situations raise LookupError("Event ID not specified") return tuple(ins.events)[0] for id in self._ids: if id in ins.events: return ins.events.first(id) @property def default(self) -> T | None: # Configure version based defaults here return self._default @abc.abstractmethod def _get(self, ev_or_ins: Any) -> T | None: ... @abc.abstractmethod def _set(self, ev_or_ins: Any, value: T) -> None: ... @final def __get__(self, ins: Any, owner: Any = None) -> T | Self | None: if ins is None: return self if owner is None: return NotImplemented event: Any = self._get_event(ins) if event is not None: return self._get(event) return self.default @final def __set__(self, ins: Any, value: T) -> None: if self._readonly: raise PropertyCannotBeSet(*self._ids) event: Any = self._get_event(ins) if event is not None: self._set(event, value) else: raise PropertyCannotBeSet(*self._ids) class FlagProp(PropBase[bool]): """Properties derived from enum flags.""" def __init__( self, flag: enum.IntFlag, *ids: EventEnum, prop: str = "flags", inverted: bool = False, default: bool | None = None, ) -> None: """ Args: flag: The flag which is to be checked for. id: Event ID (required for MultiEventModel). prop: The dict key which contains the flags in a `Struct`. inverted: If this is true, property getter and setters invert the value to be set / returned. """ self._flag = flag self._flag_type = type(flag) self._prop = prop self._inverted = inverted super().__init__(*ids, default=default) def _get(self, ev_or_ins: Any) -> bool | None: if isinstance(ev_or_ins, (ItemModel, StructEventBase)): flags = ev_or_ins[self._prop] else: flags = ev_or_ins.value # type: ignore if flags is not None: retbool = self._flag in self._flag_type(flags) return not retbool if self._inverted else retbool def _set(self, ev_or_ins: Any, value: bool) -> None: if self._inverted: value = not value if isinstance(ev_or_ins, (ItemModel, StructEventBase)): if value: ev_or_ins[self._prop] |= self._flag else: ev_or_ins[self._prop] &= ~self._flag else: if value: ev_or_ins.value |= self._flag # type: ignore else: ev_or_ins.value &= ~self._flag # type: ignore class KWProp(NamedPropMixin, RWProperty[T]): """Properties derived from non-local event values. These values are passed to the class constructor as keyword arguments. """ def __get__(self, ins: ModelBase | None, owner: Any = None) -> T | Self: if ins is None: return self if owner is None: return NotImplemented return ins._kw[self._prop] def __set__(self, ins: ModelBase, value: T) -> None: if self._prop not in ins._kw: raise KeyError(self._prop) ins._kw[self._prop] = value class EventProp(PropBase[T]): """Properties bound directly to one of fixed size or string events.""" def _get(self, ev_or_ins: AnyEvent) -> T | None: return ev_or_ins.value def _set(self, ev_or_ins: AnyEvent, value: T) -> None: ev_or_ins.value = value class NestedProp(ROProperty[EMT_co]): def __init__(self, type: type[EMT_co], *ids: EventEnum) -> None: self._ids = ids self._type = type def __get__(self, ins: EventModel, owner: Any = None) -> EMT_co: if owner is None: return NotImplemented return self._type(ins.events.subtree(lambda e: e.id in self._ids)) class StructProp(PropBase[T], NamedPropMixin): """Properties obtained from a :class:`construct.Struct`.""" def __init__(self, *ids: EventEnum, prop: str | None = None, **kwds: Any) -> None: super().__init__(*ids, **kwds) NamedPropMixin.__init__(self, prop) def _get(self, ev_or_ins: ItemModel[Any]) -> T | None: return ev_or_ins[self._prop] def _set(self, ev_or_ins: ItemModel[Any], value: T) -> None: ev_or_ins[self._prop] = value ================================================ FILE: pyflp/_events.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains implementations for various types of event data and its container. These types serve as the backbone for model creation and simplify marshalling and unmarshalling. """ from __future__ import annotations import abc import enum import warnings from collections.abc import Callable, Iterable, Iterator, Sequence from dataclasses import dataclass, field from itertools import zip_longest from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Tuple, cast import construct as c from sortedcontainers import SortedList from typing_extensions import Concatenate, TypeAlias from pyflp.exceptions import ( EventIDOutOfRange, InvalidEventChunkSize, PropertyCannotBeSet, ) from pyflp.types import RGBA, P, T, AnyContainer, AnyListContainer, AnyList, AnyDict BYTE: Final = 0 WORD: Final = 64 DWORD: Final = 128 TEXT: Final = 192 DATA: Final = 208 NEW_TEXT_IDS: Final = ( TEXT + 49, # ArrangementID.Name TEXT + 39, # DisplayGroupID.Name TEXT + 47, # TrackID.Name ) class _EventEnumMeta(enum.EnumMeta): def __contains__(self, obj: object) -> bool: """Whether ``obj`` is one of the integer values of enum members. Args: obj: Can be an ``int`` or an ``EventEnum``. """ return obj in tuple(self) class EventEnum(int, enum.Enum, metaclass=_EventEnumMeta): """IDs used by events. Event values are stored as a tuple of event ID and its designated type. The types are used to serialise/deserialise events by the parser. All event names prefixed with an underscore (_) are deprecated w.r.t to the latest version of FL Studio, *to the best of my knowledge*. """ def __new__(cls, id: int, type: type[AnyEvent] | None = None): obj = int.__new__(cls, id) obj._value_ = id setattr(obj, "type", type) return obj # This allows EventBase.id to actually use EventEnum for representation and # not just equality checks. It will be much simpler to debug problematic # events, if the name of the ID is directly visible. @classmethod def _missing_(cls, value: object) -> EventEnum | None: """Allows unknown IDs in the range of 0-255.""" if isinstance(value, int) and 0 <= value <= 255: # First check in existing subclasses for sc in cls.__subclasses__(): if value in sc: return sc(value) # Else create a new pseudo member pseudo_member = cls._value2member_map_.get(value, None) if pseudo_member is None: new_member = int.__new__(cls, value) new_member._name_ = str(value) new_member._value_ = value pseudo_member = cls._value2member_map_.setdefault(value, new_member) return cast(EventEnum, pseudo_member) # Raises ValueError in Enum.__new__ class EventBase(Generic[T]): """Generic ABC representing an event.""" STRUCT: c.Construct[T, T] ALLOWED_IDS: ClassVar[Sequence[int]] = [] def __init__(self, id: EventEnum, data: bytes, **kwds: Any) -> None: if self.ALLOWED_IDS and id not in self.ALLOWED_IDS: raise EventIDOutOfRange(id, *self.ALLOWED_IDS) if id < TEXT: if id < WORD: expected_size = 1 elif id < DWORD: expected_size = 2 else: expected_size = 4 if len(data) != expected_size: raise InvalidEventChunkSize(expected_size, len(data)) self.id = EventEnum(id) self._kwds = kwds self.value = self.STRUCT.parse(data, **self._kwds) def __eq__(self, o: object) -> bool: if not isinstance(o, EventBase): raise TypeError(f"Cannot find equality of an {type(o)} and {type(self)!r}") return self.id == o.id and self.value == cast(EventBase[T], o).value def __ne__(self, o: object) -> bool: if not isinstance(o, EventBase): raise TypeError(f"Cannot find inequality of a {type(o)} and {type(self)!r}") return self.id != o.id or self.value != cast(EventBase[T], o).value def __bytes__(self) -> bytes: id = c.Byte.build(self.id) data = self.STRUCT.build(self.value, **self._kwds) if self.id < TEXT: return id + data length = c.VarInt.build(len(data)) return id + length + data def __repr__(self) -> str: return f"<{type(self)!r}(id={self.id!r}, value={self.value!r})>" @property def size(self) -> int: """Serialised event size (in bytes).""" if self.id >= TEXT: return len(bytes(self)) elif self.id >= DWORD: return 5 elif self.id >= WORD: return 3 else: return 2 AnyEvent: TypeAlias = EventBase[Any] class ByteEventBase(EventBase[T]): """Base class of events used for storing 1 byte data.""" ALLOWED_IDS = range(BYTE, WORD) def __init__(self, id: EventEnum, data: bytes) -> None: """ Args: id: **0** to **63**. data: Event data of size 1. Raises: EventIDOutOfRangeError: When ``id`` is not in range of 0-63. InvalidEventChunkSizeError: When size of `data` is not 1. """ super().__init__(id, data) class BoolEvent(ByteEventBase[bool]): """An event used for storing a boolean.""" STRUCT = c.Flag class I8Event(ByteEventBase[int]): """An event used for storing a 1 byte signed integer.""" STRUCT = c.Int8sl class U8Event(ByteEventBase[int]): """An event used for storing a 1 byte unsigned integer.""" STRUCT = c.Int8ul class WordEventBase(EventBase[int], abc.ABC): """Base class of events used for storing 2 byte data.""" ALLOWED_IDS = range(WORD, DWORD) def __init__(self, id: EventEnum, data: bytes) -> None: """ Args: id: **64** to **127**. data: Event data of size 2. Raises: EventIDOutOfRangeError: When ``id`` is not in range of 64-127. InvalidEventChunkSizeError: When size of `data` is not 2. """ super().__init__(id, data) class I16Event(WordEventBase): """An event used for storing a 2 byte signed integer.""" STRUCT = c.Int16sl class U16Event(WordEventBase): """An event used for storing a 2 byte unsigned integer.""" STRUCT = c.Int16ul class DWordEventBase(EventBase[T], abc.ABC): """Base class of events used for storing 4 byte data.""" ALLOWED_IDS = range(DWORD, TEXT) def __init__(self, id: EventEnum, data: bytes) -> None: """ Args: id: **128** to **191**. data: Event data of size 4. Raises: EventIDOutOfRangeError: When ``id`` is not in range of 128-191. InvalidEventChunkSizeError: When size of `data` is not 4. """ super().__init__(id, data) class F32Event(DWordEventBase[float]): """An event used for storing 4 byte floats.""" STRUCT = c.Float32l class I32Event(DWordEventBase[int]): """An event used for storing a 4 byte signed integer.""" STRUCT = c.Int32sl class U32Event(DWordEventBase[int]): """An event used for storing a 4 byte unsigned integer.""" STRUCT = c.Int32ul class U16TupleEvent(DWordEventBase[Tuple[int, int]]): """An event used for storing a two-tuple of 2 byte unsigned integers.""" STRUCT = c.ExprAdapter( c.Int16ul[2], lambda obj_, *_: tuple(obj_), # type: ignore lambda obj_, *_: list(obj_), # type: ignore ) class ColorEvent(DWordEventBase[RGBA]): """A 4 byte event which stores a color.""" STRUCT = c.ExprAdapter( c.Bytes(4), lambda obj, *_: RGBA.from_bytes(obj), # type: ignore lambda obj, *_: bytes(obj), # type: ignore ) class StrEventBase(EventBase[str]): """Base class of events used for storing strings.""" ALLOWED_IDS = (*range(TEXT, DATA), *NEW_TEXT_IDS) def __init__(self, id: EventEnum, data: bytes) -> None: """ Args: id: **192** to **207** or in :attr:`NEW_TEXT_IDS`. data: ASCII or UTF16 encoded string data. Raises: ValueError: When ``id`` is not in 192-207 or in :attr:`NEW_TEXT_IDS`. """ super().__init__(id, data) class AsciiEvent(StrEventBase): if TYPE_CHECKING: STRUCT: c.ExprAdapter[str, str, str, str] else: STRUCT = c.ExprAdapter( c.GreedyString("ascii"), lambda obj, *_: obj.rstrip("\0"), lambda obj, *_: obj + "\0", ) class UnicodeEvent(StrEventBase): if TYPE_CHECKING: STRUCT: c.ExprAdapter[str, str, str, str] else: STRUCT = c.ExprAdapter( c.GreedyString("utf-16-le"), lambda obj, *_: obj.rstrip("\0"), lambda obj, *_: obj + "\0", ) class StructEventBase(EventBase[AnyContainer], AnyDict): """Base class for events used for storing fixed size structured data. Consists of a collection of POD types like int, bool, float, but not strings. Its size is determined by the event as well as FL version. """ def __init__(self, id: EventEnum, data: bytes) -> None: super().__init__(id, data, len=len(data)) self.data = self.value # Akin to UserDict.__init__ def __setitem__(self, key: str, value: Any) -> None: if key not in self: raise KeyError if self[key] is None: raise PropertyCannotBeSet self.data[key] = value class ListEventBase(EventBase[AnyListContainer], AnyList): """Base class for events storing an array of structured data. Attributes: kwds: Keyword args passed to :meth:`STRUCT.parse` & :meth:`STRUCT.build`. """ STRUCT: c.Subconstruct[Any, Any, Any, Any] SIZES: ClassVar[list[int]] = [] """Manual :meth:`STRUCT.sizeof` override(s).""" def __init__(self, id: EventEnum, data: bytes, **kwds: Any) -> None: super().__init__(id, data, **kwds) self._struct_size: int | None = None if not self.SIZES: self._struct_size = self.STRUCT.subcon.sizeof() for size in self.SIZES: if not len(data) % size: self._struct_size = size break if self._struct_size is None: # pragma: no cover warnings.warn( f"Cannot parse event {id} as event size {len(data)} " f"is not a multiple of struct size(s) {self.SIZES}" ) else: self.data = self.value # Akin to UserList.__init__ class UnknownDataEvent(EventBase[bytes]): """Used for events whose structure is unknown as of yet.""" STRUCT = c.GreedyBytes @dataclass(order=True) class IndexedEvent: r: int """Root index of occurence of :attr:`e`.""" e: AnyEvent = field(compare=False) """The indexed event.""" def yields_child(func: Callable[Concatenate[EventTree, P], Iterator[EventTree]]): """Adds an :class:`EventTree` to its parent's list of children and yields it.""" def wrapper(self: EventTree, *args: P.args, **kwds: P.kwargs): for child in func(self, *args, **kwds): self.children.append(child) yield child return wrapper class EventTree: """Provides mutable "views" which propagate changes back to parents. This tree is analogous to the hierarchy used by models. Attributes: parent: Immediate ancestor / parent. Defaults to self. root: Parent of all parent trees. children: List of children. """ def __init__( self, parent: EventTree | None = None, init: Iterable[IndexedEvent] | None = None, ) -> None: """Create a new dictionary with an optional :attr:`parent`.""" self.children: list[EventTree] = [] self.lst: list[IndexedEvent] = SortedList(init or []) # type: ignore self.parent = parent if parent is not None: parent.children.append(self) while parent is not None and parent.parent is not None: parent = parent.parent self.root = parent or self def __contains__(self, id: EventEnum) -> bool: """Whether the key :attr:`id` exists in the list.""" return any(ie.e.id == id for ie in self.lst) def __eq__(self, o: object) -> bool: """Compares equality of internal lists.""" if not isinstance(o, EventTree): return NotImplemented return self.lst == o.lst def __iadd__(self, *events: AnyEvent) -> None: """Analogous to :meth:`list.extend`.""" for event in events: self.append(event) def __iter__(self) -> Iterator[AnyEvent]: return (ie.e for ie in self.lst) def __len__(self) -> int: return len(self.lst) def __repr__(self) -> str: return f"EventTree({len(self.ids)} IDs, {len(self)} events)" def _get_ie(self, *ids: EventEnum) -> Iterator[IndexedEvent]: return (ie for ie in self.lst if ie.e.id in ids) def _recursive(self, action: Callable[[EventTree], None]) -> None: """Recursively performs :attr:`action` on self and all parents.""" action(self) ancestor = self.parent while ancestor is not None: action(ancestor) ancestor = ancestor.parent def append(self, event: AnyEvent) -> None: """Appends an event at its corresponding key's list's end.""" self.insert(len(self), event) def count(self, id: EventEnum) -> int: """Returns the count of the events with :attr:`id`.""" return len(list(self._get_ie(id))) @yields_child def divide(self, separator: EventEnum, *ids: EventEnum) -> Iterator[EventTree]: """Yields subtrees containing events separated by ``separator`` infinitely.""" el: list[IndexedEvent] = [] first = True for ie in self.lst: if ie.e.id == separator: if not first: yield EventTree(self, el) el = [] else: first = False if ie.e.id in ids: el.append(ie) yield EventTree(self, el) # Yield the last one def first(self, id: EventEnum) -> AnyEvent: """Returns the first event with :attr:`id`. Raises: KeyError: An event with :attr:`id` isn't found. """ try: return next(self.get(id)) except StopIteration as exc: raise KeyError(id) from exc def get(self, *ids: EventEnum) -> Iterator[AnyEvent]: """Yields events whose ID is one of :attr:`ids`.""" return (e for e in self if e.id in ids) @yields_child def group(self, *ids: EventEnum) -> Iterator[EventTree]: """Yields EventTrees of zip objects of events with matching :attr:`ids`.""" for iet in zip_longest(*(self._get_ie(id) for id in ids)): # unpack magic yield EventTree(self, [ie for ie in iet if ie]) # filter out None values def insert(self, pos: int, e: AnyEvent) -> None: """Inserts :attr:`ev` at :attr:`pos` in this and all parent trees.""" rootidx = sorted(self.indexes)[pos] if len(self) else 0 # Shift all root indexes after rootidx by +1 to prevent collisions # while sorting the entire list by root indexes before serialising. for ie in self.root.lst: if ie.r >= rootidx: ie.r += 1 self._recursive(lambda et: et.lst.add(IndexedEvent(rootidx, e))) # type: ignore def pop(self, id: EventEnum, pos: int = 0) -> AnyEvent: """Pops the event with ``id`` at ``pos`` in ``self`` and all parents.""" if id not in self.ids: raise KeyError(id) ie = [ie for ie in self.lst if ie.e.id == id][pos] self._recursive(lambda et: et.lst.remove(ie)) # Shift all root indexes of events after rootidx by -1. for root_ie in self.root.lst: if root_ie.r >= ie.r: root_ie.r -= 1 return ie.e def remove(self, id: EventEnum, pos: int = 0) -> None: """Removes the event with ``id`` at ``pos`` in ``self`` and all parents.""" self.pop(id, pos) @yields_child def separate(self, id: EventEnum) -> Iterator[EventTree]: """Yields a separate ``EventTree`` for every event with matching ``id``.""" yield from (EventTree(self, [ie]) for ie in self._get_ie(id)) def subtree(self, select: Callable[[AnyEvent], bool | None]) -> EventTree: """Returns a mutable view containing events for which ``select`` was True. Caution: Always use this function to create a mutable view. Maintaining chilren and passing parent to a child are best done here. """ el: list[IndexedEvent] = [] for ie in self.lst: if select(ie.e): el.append(ie) obj = EventTree(self, el) self.children.append(obj) return obj @yields_child def subtrees( self, select: Callable[[AnyEvent], bool | None], repeat: int ) -> Iterator[EventTree]: """Yields mutable views till ``select`` and ``repeat`` are satisfied. Args: select: Called for every event in this dictionary by iterating over a chained, sorted list. Returns True if event must be included. Once it returns False, rest of them are ignored and resulting EventTree is returned. Return None to skip an event. repeat: Use -1 for infinite iterations. """ el: list[IndexedEvent] = [] for ie in self.lst: if not repeat: return result = select(ie.e) if result is False: yield EventTree(self, el) el = [ie] # Don't skip current event repeat -= 1 elif result is not None: el.append(ie) @property def ids(self) -> frozenset[EventEnum]: return frozenset(ie.e.id for ie in self.lst) @property def indexes(self) -> frozenset[int]: """Returns root indexes for all events in ``self``.""" return frozenset(ie.r for ie in self.lst) ================================================ FILE: pyflp/_models.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the ABCs used by model classes and some shared classes.""" from __future__ import annotations import abc import functools from typing import ( Any, Callable, Generic, Iterable, Protocol, Sequence, TypeVar, Union, overload, runtime_checkable, ) import construct as c from pyflp._events import EventTree, ListEventBase, StructEventBase VE = TypeVar("VE", bound=Union[StructEventBase, ListEventBase]) class ModelBase(abc.ABC): def __init__(self, *args: Any, **kw: Any) -> None: self._kw = kw class ItemModel(ModelBase, Generic[VE]): """Base class for event-less models.""" def __init__(self, item: c.Container[Any], index: int, parent: VE, **kw: Any) -> None: """Create a new item model. Args: item: Parsed :class:`construct.Struct` instance from :attr:`parent`. index: 0-based index used to propagate changes back to :attr:`parent`. parent: A :class:`StructEventBase` or :class:`ListEventBase` instance. """ self._item = item self._index = index self._parent = parent super().__init__(**kw) def __getitem__(self, prop: str): return self._item[prop] def __setitem__(self, prop: str, value: Any) -> None: self._item[prop] = value if not isinstance(self._parent, ListEventBase): raise NotImplementedError self._parent[self._index] = self._item class EventModel(ModelBase): def __init__(self, events: EventTree, **kw: Any) -> None: super().__init__(**kw) self.events = events def __eq__(self, o: object) -> bool: if not isinstance(o, type(self)): raise TypeError(f"Cannot compare {type(o)!r} with {type(self)!r}") return o.events == self.events MT_co = TypeVar("MT_co", bound=ModelBase, covariant=True) EMT_co = TypeVar("EMT_co", bound=EventModel, covariant=True) @runtime_checkable class ModelCollection(Iterable[MT_co], Protocol[MT_co]): @overload def __getitem__(self, i: int | str) -> MT_co: ... @overload def __getitem__(self, i: slice) -> Sequence[MT_co]: ... def supports_slice(func: Callable[[ModelCollection[MT_co], str | int | slice], MT_co]): """Wraps a :meth:`ModelCollection.__getitem__` to return a sequence if required.""" @overload def wrapper(self: ModelCollection[MT_co], i: int | str) -> MT_co: ... @overload def wrapper(self: ModelCollection[MT_co], i: slice) -> Sequence[MT_co]: ... @functools.wraps(func) def wrapper(self: Any, i: Any) -> MT_co | Sequence[MT_co]: if isinstance(i, slice): return [model for idx, model in enumerate(self) if idx in range(i.start, i.stop)] return func(self, i) return wrapper class ModelReprMixin: """I am too lazy to make one `__repr__()` for every model.""" def __repr__(self) -> str: mapping: dict[str, Any] = {} for var in [var for var in vars(type(self)) if not var.startswith("_")]: mapping[var] = getattr(self, var, None) params = ", ".join([f"{k}={v!r}" for k, v in mapping.items()]) return f"{type(self).__name__}({params})" ================================================ FILE: pyflp/arrangement.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by tracks and arrangements.""" from __future__ import annotations import enum from typing import Any, Iterator, Literal, Optional, cast import construct as c import construct_typed as ct from typing_extensions import TypedDict, Unpack from pyflp._adapters import FourByteBool, StdEnum from pyflp._descriptors import EventProp, NestedProp, StructProp from pyflp._events import ( DATA, DWORD, TEXT, WORD, AnyEvent, EventEnum, EventTree, ListEventBase, StructEventBase, U8Event, U16Event, U16TupleEvent, ) from pyflp._models import ( EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice, ) from pyflp.channel import Channel, ChannelRack from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet from pyflp.pattern import Pattern, Patterns from pyflp.timemarker import TimeMarker, TimeMarkerID from pyflp.types import RGBA, FLVersion __all__ = [ "Arrangements", "Arrangement", "Track", "TrackMotion", "TrackPress", "TrackSync", "ChannelPLItem", "PatternPLItem", ] class PLSelectionEvent(StructEventBase): STRUCT = c.Struct("start" / c.Optional(c.Int32ul), "end" / c.Optional(c.Int32ul)).compile() class PlaylistEvent(ListEventBase): STRUCT = c.GreedyRange( c.Struct( "position" / c.Int32ul, # 4 "pattern_base" / c.Int16ul * "Always 20480", # 6 "item_index" / c.Int16ul, # 8 "length" / c.Int32ul, # 12 "track_rvidx" / c.Int16ul * "Stored reversed i.e. Track 1 would be 499", # 14 "group" / c.Int16ul, # 16 "_u1" / c.Bytes(2) * "Always (120, 0)", # 18 "item_flags" / c.Int16ul * "Always (64, 0)", # 20 "_u2" / c.Bytes(4) * "Always (64, 100, 128, 128)", # 24 "start_offset" / c.Float32l, # 28 "end_offset" / c.Float32l, # 32 "_u3" / c.If(c.this._params["new"], c.Bytes(28)) * "New in FL 21", # 60 ) ) SIZES = [32, 60] def __init__(self, id: EventEnum, data: bytes) -> None: super().__init__(id, data, new=not len(data) % 60) @enum.unique class TrackMotion(ct.EnumBase): Stay = 0 OneShot = 1 MarchWrap = 2 MarchStay = 3 MarchStop = 4 Random = 5 ExclusiveRandom = 6 @enum.unique class TrackPress(ct.EnumBase): Retrigger = 0 HoldStop = 1 HoldMotion = 2 Latch = 3 @enum.unique class TrackSync(ct.EnumBase): Off = 0 QuarterBeat = 1 HalfBeat = 2 Beat = 3 TwoBeats = 4 FourBeats = 5 Auto = 6 class HeightAdapter(ct.Adapter[float, float, str, str]): def _decode(self, obj: float, *_: Any) -> str: return str(int(obj * 100)) + "%" def _encode(self, obj: str, *_: Any) -> float: return int(obj[:-1]) / 100 class TrackEvent(StructEventBase): STRUCT = c.Struct( "iid" / c.Optional(c.Int32ul), # 4 "color" / c.Optional(c.Int32ul), # 8 "icon" / c.Optional(c.Int32ul), # 12 "enabled" / c.Optional(c.Flag), # 13 "height" / c.Optional(HeightAdapter(c.Float32l)), # 17 "locked_height" / c.Optional(c.Int32sl), # 21 "content_locked" / c.Optional(c.Flag), # 22 "motion" / c.Optional(StdEnum[TrackMotion](c.Int32ul)), # 26 "press" / c.Optional(StdEnum[TrackPress](c.Int32ul)), # 30 "trigger_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 34 "queued" / c.Optional(FourByteBool), # 38 "tolerant" / c.Optional(FourByteBool), # 42 "position_sync" / c.Optional(StdEnum[TrackSync](c.Int32ul)), # 46 "grouped" / c.Optional(c.Flag), # 47 "locked" / c.Optional(c.Flag), # 48 "_u1" / c.Optional(c.GreedyBytes), # * 66 as of 20.9.1 ).compile() @enum.unique class ArrangementsID(EventEnum): TimeSigNum = (17, U8Event) TimeSigBeat = (18, U8Event) Current = (WORD + 36, U16Event) _LoopPos = (DWORD + 24, U16TupleEvent) #: 1.3.8+ PLSelection = (DATA + 9, PLSelectionEvent) """.. versionadded:: v2.1.0""" @enum.unique class ArrangementID(EventEnum): New = (WORD + 35, U16Event) # _PlaylistItem = DWORD + 1 Name = TEXT + 49 Playlist = (DATA + 25, PlaylistEvent) @enum.unique class TrackID(EventEnum): Name = TEXT + 47 Data = (DATA + 30, TrackEvent) class PLItemBase(ItemModel[PlaylistEvent], ModelReprMixin): group = StructProp[int]() """Returns 0 for no group, else a group number for clips in the same group.""" length = StructProp[int]() """PPQ-dependant quantity.""" muted = StructProp[bool]() """Whether muted / disabled in the playlist. *New in FL Studio v9.0.0*.""" @property def offsets(self) -> tuple[float, float]: """Returns a ``(start, end)`` offset tuple. An offset is the distance from the item's actual start or end. """ return (self["start_offset"], self["end_offset"]) @offsets.setter def offsets(self, value: tuple[float, float]) -> None: self["start_offset"], self["end_offset"] = value position = StructProp[int]() """PPQ-dependant quantity.""" class ChannelPLItem(PLItemBase, ModelReprMixin): """An audio clip or automation on the playlist of an arrangement. *New in FL Studio v2.0.1*. """ @property def channel(self) -> Channel: return self._kw["channel"] @channel.setter def channel(self, channel: Channel) -> None: self._kw["channel"] = channel self["item_index"] = channel.iid class PatternPLItem(PLItemBase, ModelReprMixin): """A pattern block or clip on the playlist of an arrangement. *New in FL Studio v7.0.0*. """ @property def pattern(self) -> Pattern: return self._kw["pattern"] @pattern.setter def pattern(self, pattern: Pattern) -> None: self._kw["pattern"] = pattern self["item_index"] = pattern.iid + self["pattern_base"] class _TrackColorProp(StructProp[RGBA]): def _get(self, ev_or_ins: Any) -> RGBA | None: value = cast(Optional[int], super()._get(ev_or_ins)) if value is not None: return RGBA.from_bytes(value.to_bytes(4, "little")) def _set(self, ev_or_ins: Any, value: RGBA) -> None: super()._set(ev_or_ins, int.from_bytes(bytes(value), "little")) # type: ignore class _TrackKW(TypedDict): items: list[PLItemBase] class Track(EventModel, ModelCollection[PLItemBase]): """Represents a track in an arrangement on which playlist items are arranged. ![](https://bit.ly/3de6R8y) """ def __init__(self, events: EventTree, **kw: Unpack[_TrackKW]) -> None: super().__init__(events, **kw) def __getitem__(self, index: int | slice | str): if isinstance(index, str): return NotImplemented return self._kw["items"][index] def __iter__(self) -> Iterator[PLItemBase]: """An iterator over :attr:`items`.""" yield from self._kw["items"] def __len__(self) -> int: return len(self._kw["items"]) def __repr__(self) -> str: return f"Track(name={self.name}, iid={self.iid}, {len(self)} items)" color = _TrackColorProp(TrackID.Data) """Defaults to #485156 (dark slate gray). ![](https://bit.ly/3yVGGuW) Note: Unlike :attr:`Channel.color` and :attr:`Insert.color`, values below ``20`` for any color component (i.e red, green or blue) are NOT ignored by FL Studio. """ content_locked = StructProp[bool](TrackID.Data) """:guilabel:`Lock to content`, defaults to ``False``.""" enabled = StructProp[bool](TrackID.Data) """![](https://bit.ly/3eGd91O)""" grouped = StructProp[bool](TrackID.Data) """Whether grouped with the track above (index - 1) or not. ![](https://bit.ly/3yXO5tM) :guilabel:`&Group with above track` """ height = StructProp[str](TrackID.Data) """Track height in FL's interface. Linear. :guilabel:`&Size`.""" icon = StructProp[int](TrackID.Data) """Returns ``0`` if not set, else an internal icon ID. ![](https://bit.ly/3gln8Kc) :guilabel:`Change icon` """ iid = StructProp[int](TrackID.Data) """An integer in the range of 1 to :attr:`Arrangements.max_tracks`.""" locked = StructProp[bool](TrackID.Data) """Whether the tracked is in a locked state. ![](https://bit.ly/3VFG6eP) """ motion = StructProp[TrackMotion](TrackID.Data) """:guilabel:`&Performance settings`, defaults to :attr:`TrackMotion.Stay`.""" name = EventProp[str](TrackID.Name) """Returns a string or ``None`` if not set.""" position_sync = StructProp[TrackSync](TrackID.Data) """:guilabel:`&Performance settings`, defaults to :attr:`TrackSync.Off`.""" press = StructProp[TrackPress](TrackID.Data) """:guilabel:`&Performance settings`, defaults to :attr:`TrackPress.Retrigger`.""" tolerant = StructProp[bool](TrackID.Data) """:guilabel:`&Performance settings`, defaults to ``True``.""" trigger_sync = StructProp[TrackSync](TrackID.Data) """:guilabel:`&Performance settings`, defaults to :attr:`TrackSync.FourBeats`.""" queued = StructProp[bool](TrackID.Data) """:guilabel:`&Performance settings`, defaults to ``False``.""" class _ArrangementKW(TypedDict): channels: ChannelRack patterns: Patterns version: FLVersion class Arrangement(EventModel): """Contains the timemarkers and tracks in an arrangement. ![](https://bit.ly/3B6is1z) *New in FL Studio v12.9.1*: Support for multiple arrangements. """ def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None: super().__init__(events, **kw) def __repr__(self) -> str: return "Arrangement(iid={}, name={}, {} timemarkers, {} tracks)".format( self.iid, repr(self.name), len(tuple(self.timemarkers)), len(tuple(self.tracks)), ) iid = EventProp[int](ArrangementID.New) """A 1-based internal index.""" name = EventProp[str](ArrangementID.Name) """Name of the arrangement; defaults to **Arrangement**.""" @property def timemarkers(self) -> Iterator[TimeMarker]: yield from (TimeMarker(ed) for ed in self.events.group(*TimeMarkerID)) @property def tracks(self) -> Iterator[Track]: pl_evt = None max_idx = 499 if self._kw["version"] >= FLVersion(12, 9, 1) else 198 channels = {channel.iid: channel for channel in self._kw["channels"]} patterns = {pattern.iid: pattern for pattern in self._kw["patterns"]} if ArrangementID.Playlist in self.events.ids: pl_evt = cast(PlaylistEvent, self.events.first(ArrangementID.Playlist)) for track_idx, ed in enumerate(self.events.divide(TrackID.Data, *TrackID)): if pl_evt is None: yield Track(ed, items=[]) continue items: list[PLItemBase] = [] for i, item in enumerate(pl_evt): if max_idx - item["track_rvidx"] != track_idx: continue if item["item_index"] <= item["pattern_base"]: iid = item["item_index"] items.append(ChannelPLItem(item, i, pl_evt, channel=channels[iid])) else: num = item["item_index"] - item["pattern_base"] items.append(PatternPLItem(item, i, pl_evt, pattern=patterns[num])) yield Track(ed, items=items) # TODO Find whether time is set to signature or division mode. class TimeSignature(EventModel, ModelReprMixin): """![](https://bit.ly/3EYiMmy)""" def __str__(self) -> str: return f"Global time signature: {self.num}/{self.beat}" num = EventProp[int](ArrangementsID.TimeSigNum) """Beats per bar in time division & numerator in time signature mode. | Min | Max | Default | |-----|-----|---------| | 1 | 16 | 4 | """ beat = EventProp[int](ArrangementsID.TimeSigBeat) """Steps per beat in time division & denominator in time signature mode. In time signature mode it can be 2, 4, 8 or 16 but in time division mode: | Min | Max | Default | |-----|-----|---------| | 1 | 16 | 4 | """ class Arrangements(EventModel, ModelCollection[Arrangement]): """Iterator over arrangements in the project and some related properties.""" def __init__(self, events: EventTree, **kw: Unpack[_ArrangementKW]) -> None: super().__init__(events, **kw) @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Arrangement: """Returns an arrangement based either on its index or name. Args: i: The index of the arrangement in which they occur or :attr:`Arrangement.name` of the arrangement to lookup for or a slice of indexes. Raises: ModelNotFound: An :class:`Arrangement` with the specifed name or index isn't found. """ for idx, arr in enumerate(self): if (isinstance(i, str) and i == arr.name) or idx == i: return arr raise ModelNotFound(i) # TODO Verify ArrangementsID.Current is the end # FL changed event ordering a lot, the latest being the most easiest to # parse; it contains ArrangementID.New event followed by TimeMarker events # followed by 500 TrackID events. TimeMarkers occured before new arrangement # event in initial versions of FL20, making them harder to group. # TODO This logic might not work on older versions of FL. def __iter__(self) -> Iterator[Arrangement]: """Yields :class:`Arrangement` found in the project. Raises: NoModelsFound: When no arrangements are found. """ arrnew_occured = False def select(e: AnyEvent) -> bool | None: nonlocal arrnew_occured if e.id == ArrangementID.New: if arrnew_occured: return False arrnew_occured = True if e.id in (*ArrangementID, *TimeMarkerID, *TrackID): return True if e.id == ArrangementsID.Current: return False # Yield out last arrangement yield from (Arrangement(ed, **self._kw) for ed in self.events.subtrees(select, len(self))) def __len__(self) -> int: """The number of arrangements present in the project. Raises: NoModelsFound: When no arrangements are found. """ if ArrangementID.New not in self.events.ids: raise NoModelsFound return self.events.count(ArrangementID.New) def __repr__(self) -> str: return f"{len(self)} arrangements" @property def current(self) -> Arrangement | None: """Currently selected arrangement (via FL's interface). Raises: ModelNotFound: When the underlying event value points to an invalid arrangement index. """ if ArrangementsID.Current in self.events.ids: event = self.events.first(ArrangementsID.Current) index: int = event.value try: return list(self)[index] except IndexError as exc: raise ModelNotFound(index) from exc @property def loop_pos(self) -> tuple[int, int] | None: """Playlist loop start and end points. PPQ dependant. .. versionchanged:: v2.1.0 :attr:`ArrangementsID.PLSelection` is used by default while :attr:`ArrangementsID._LoopPos` is a fallback. *New in FL Studio v1.3.8*. """ if ArrangementsID.PLSelection in self.events: event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection)) return event["start"], event["end"] if ArrangementsID._LoopPos in self.events: return self.events.first(ArrangementsID._LoopPos).value @loop_pos.setter def loop_pos(self, value: tuple[int, int]) -> None: if ArrangementsID.PLSelection in self.events: event = cast(PLSelectionEvent, self.events.first(ArrangementsID.PLSelection)) event["start"], event["end"] = value elif ArrangementsID._LoopPos in self.events: self.events.first(ArrangementsID._LoopPos).value = value else: raise PropertyCannotBeSet(ArrangementsID.PLSelection, ArrangementsID._LoopPos) @property def max_tracks(self) -> Literal[500, 199]: return 500 if self._kw["version"] >= FLVersion(12, 9, 1) else 199 time_signature = NestedProp( TimeSignature, ArrangementsID.TimeSigNum, ArrangementsID.TimeSigBeat ) """Project time signature (also used by playlist). :menuselection:`Options --> &Project general settings --> Time settings` """ ================================================ FILE: pyflp/channel.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by channels and the channel rack.""" from __future__ import annotations import enum import pathlib from typing import Any, Iterator, Literal, Tuple, cast import construct as c import construct_typed as ct from pyflp._adapters import LinearMusical, List2Tuple, Log2, LogNormal, StdEnum from pyflp._descriptors import EventProp, FlagProp, NestedProp, StructProp from pyflp._events import ( DATA, DWORD, TEXT, WORD, BoolEvent, EventEnum, F32Event, I8Event, I32Event, StructEventBase, U8Event, U16Event, U16TupleEvent, U32Event, ) from pyflp._models import EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet from pyflp.plugin import BooBass, FruitKick, Plucked, PluginID, PluginProp, VSTPlugin from pyflp.types import RGBA, MusicalTime __all__ = [ "ArpDirection", "Automation", "AutomationPoint", "Channel", "Instrument", "Layer", "ChannelRack", "ChannelNotFound", "DeclickMode", "LFOShape", "ReverbType", "FX", "Reverb", "Delay", "Envelope", "SamplerLFO", "Tracking", "Keyboard", "LevelAdjusts", "StretchMode", "Time", "TimeStretching", "Polyphony", "Playback", "ChannelType", ] EnvelopeName = Literal["Panning", "Volume", "Mod X", "Mod Y", "Pitch"] LFOName = EnvelopeName class ChannelNotFound(ModelNotFound, KeyError): pass class AutomationEvent(StructEventBase): @staticmethod def _get_position(stream: c.StreamType, index: int) -> float: cur = stream.tell() position = 0.0 for i in range(index + 1): stream.seek(21 + (i * 24)) position += c.Float64l.parse_stream(stream) stream.seek(cur) return position STRUCT = c.Struct( "_u1" / c.Bytes(4), # 4 # ? Always 1 "lfo.amount" / c.Int32sl, "_u2" / c.Bytes(1), # 9 "_u3" / c.Bytes(2), # 11 "_u4" / c.Bytes(2), # 13 # ? Always 0 "_u5" / c.Bytes(4), # 17 "points" / c.PrefixedArray( c.Int32ul, # 21 c.Struct( "_offset" / c.Float64l * "Change in X-axis w.r.t last point", "position" # TODO Implement a setter / c.IfThenElse( lambda ctx: ctx._index > 0, c.Computed(lambda ctx: AutomationEvent._get_position(ctx._io, ctx._index)), c.Computed(lambda ctx: ctx["_offset"]), ), "value" / c.Float64l, "tension" / c.Float32l, "_u1" / c.Bytes(4), # Linked to tension ), # 24 per struct ), "_u6" / c.GreedyBytes, # TODO Upto a whooping 112 bytes ) class DelayEvent(StructEventBase): STRUCT = c.Struct( "feedback" / c.Optional(c.Int32ul), "pan" / c.Optional(c.Int32sl), "pitch_shift" / c.Optional(c.Int32sl), "echoes" / c.Optional(c.Int32ul), "time" / c.Optional(c.Int32ul), ).compile() @enum.unique class _EnvLFOFlags(enum.IntFlag): EnvelopeTempoSync = 1 << 0 Unknown = 1 << 2 # Occurs for volume envlope only. Likely a bug in FL's serialiser LFOTempoSync = 1 << 1 LFOPhaseRetrig = 1 << 5 @enum.unique class LFOShape(ct.EnumBase): """Used by :attr:`LFO.shape`.""" Sine = 0 Triangle = 1 Pulse = 2 # FL Studio 2.5.0+ class EnvelopeLFOEvent(StructEventBase): STRUCT = c.Struct( "flags" / c.Optional(StdEnum[_EnvLFOFlags](c.Int32sl)), # 4 "envelope.enabled" / c.Optional(c.Int32sl), # 8 "envelope.predelay" / c.Optional(c.Int32sl), # 12 "envelope.attack" / c.Optional(c.Int32sl), # 16 "envelope.hold" / c.Optional(c.Int32sl), # 20 "envelope.decay" / c.Optional(c.Int32sl), # 24 "envelope.sustain" / c.Optional(c.Int32sl), # 28 "envelope.release" / c.Optional(c.Int32sl), # 32 "envelope.amount" / c.Optional(c.Int32sl), # 36 "lfo.predelay" / c.Optional(c.Int32ul), # 40 "lfo.attack" / c.Optional(c.Int32ul), # 44 "lfo.amount" / c.Optional(c.Int32sl), # 48 "lfo.speed" / c.Optional(c.Int32ul), # 52 "lfo.shape" / c.Optional(StdEnum[LFOShape](c.Int32sl)), # 56 "envelope.attack_tension" / c.Optional(c.Int32sl), # 60 "envelope.decay_tension" / c.Optional(c.Int32sl), # 64 "envelope.release_tension" / c.Optional(c.Int32sl), # 68 ).compile() class LevelAdjustsEvent(StructEventBase): STRUCT = c.Struct( "pan" / c.Optional(c.Int32sl), # 4 "volume" / c.Optional(c.Int32ul), # 8 "_u1" / c.Optional(c.Int32ul), # 12 "mod_x" / c.Optional(c.Int32sl), # 16 "mod_y" / c.Optional(c.Int32sl), # 20 ).compile() class FilterType(ct.EnumBase): FastLP = 0 LP = 1 BP = 2 HP = 3 BS = 4 LPx2 = 5 SVFLP = 6 SVFLPx2 = 7 class LevelsEvent(StructEventBase): STRUCT = c.Struct( "pan" / c.Optional(c.Int32sl), # 4 "volume" / c.Optional(c.Int32ul), # 8 "pitch_shift" / c.Optional(c.Int32sl), # 12 "filter.mod_x" / c.Optional(c.Int32ul), # 16 "filter.mod_y" / c.Optional(c.Int32ul), # 20 "filter.type" / c.Optional(StdEnum[FilterType](c.Int32ul)), # 24 ).compile() @enum.unique class ArpDirection(ct.EnumBase): """Used by :attr:`Arp.direction`.""" Off = 0 Up = 1 Down = 2 UpDownBounce = 3 UpDownSticky = 4 Random = 5 @enum.unique class DeclickMode(ct.EnumBase): OutOnly = 0 TransientNoBleeding = 1 Transient = 2 Generic = 3 Smooth = 4 Crossfade = 5 @enum.unique class _DelayFlags(enum.IntFlag): PingPong = 1 << 1 FatMode = 1 << 2 @enum.unique class StretchMode(ct.EnumBase): Stretch = -1 Resample = 0 E3Generic = 1 E3Mono = 2 SliceStretch = 3 SliceMap = 4 Auto = 5 E2Generic = 6 E2Transient = 7 E2Mono = 8 E2Speech = 9 class ParametersEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.Optional(c.Bytes(9)), # 9 "fx.remove_dc" / c.Optional(c.Flag), # 10 "delay.flags" / c.Optional(StdEnum[_DelayFlags](c.Int8ul)), # 11 "keyboard.main_pitch" / c.Optional(c.Flag), # 12 "_u2" / c.Optional(c.Bytes(28)), # 40 "arp.direction" / c.Optional(StdEnum[ArpDirection](c.Int32ul)), # 44 "arp.range" / c.Optional(c.Int32ul), # 48 "arp.chord" / c.Optional(c.Int32ul), # 52 "arp.time" / c.Optional(c.Float32l), # 56 "arp.gate" / c.Optional(c.Float32l), # 60 "arp.slide" / c.Optional(c.Flag), # 61 "_u3" / c.Optional(c.Bytes(1)), # 62 "time.full_porta" / c.Optional(c.Flag), # 63 "keyboard.add_root" / c.Optional(c.Flag), # 64 "time.gate" / c.Optional(c.Int16ul), # 66 "_u4" / c.Optional(c.Bytes(2)), # 68 "keyboard.key_region" / c.Optional(List2Tuple(c.Int32ul[2])), # 76 "_u5" / c.Optional(c.Bytes(4)), # 80 "fx.normalize" / c.Optional(c.Flag), # 81 "fx.inverted" / c.Optional(c.Flag), # 82 "_u6" / c.Optional(c.Bytes(1)), # 83 "content.declick_mode" / c.Optional(StdEnum[DeclickMode](c.Int8ul)), # 84 "fx.crossfade" / c.Optional(c.Int32ul), # 88 "fx.trim" / c.Optional(c.Int32ul), # 92 "arp.repeat" / c.Optional(c.Int32ul), # 96; FL 4.5.2+ "stretching.time" / c.Optional(LinearMusical(c.Int32ul)), # 100 "stretching.pitch" / c.Optional(c.Int32sl), # 104 "stretching.multiplier" / c.Optional(Log2(c.Int32sl, 10000)), # 108 "stretching.mode" / c.Optional(StdEnum[StretchMode](c.Int32sl)), # 112 "_u7" / c.Optional(c.Bytes(21)), # 133 "fx.start" / c.Optional(LogNormal(c.Int16ul[2], (0, 61440))), # 137 "_u8" / c.Optional(c.Bytes(4)), # 141 "fx.length" / c.Optional(LogNormal(c.Int16ul[2], (0, 61440))), # 145 "_u9" / c.Optional(c.Bytes(3)), # 148 "playback.start_offset" / c.Optional(c.Int32ul), # 152 "_u10" / c.Optional(c.Bytes(5)), # 157 "fx.fix_trim" / c.Optional(c.Flag), # 158 (FL 20.8.4 max) "_extra" / c.GreedyBytes, # * 168 as of 20.9.1 ) @enum.unique class _PolyphonyFlags(enum.IntFlag): None_ = 0 Mono = 1 << 0 Porta = 1 << 1 class PolyphonyEvent(StructEventBase): STRUCT = c.Struct( "max" / c.Optional(c.Int32ul), # 4 "slide" / c.Optional(c.Int32ul), # 8 "flags" / c.Optional(StdEnum[_PolyphonyFlags](c.Byte)), # 9 ).compile() class TrackingEvent(StructEventBase): STRUCT = c.Struct( "middle_value" / c.Optional(c.Int32ul), # 4 "pan" / c.Optional(c.Int32sl), # 8 "mod_x" / c.Optional(c.Int32sl), # 12 "mod_y" / c.Optional(c.Int32sl), # 16 ).compile() @enum.unique class ChannelID(EventEnum): IsEnabled = (0, BoolEvent) _VolByte = (2, U8Event) _PanByte = (3, U8Event) Zipped = (15, BoolEvent) # _19 = (19, BoolEvent) PingPongLoop = (20, BoolEvent) Type = (21, U8Event) RoutedTo = (22, I8Event) # FXProperties = 27 IsLocked = (32, BoolEvent) #: 12.3+ New = (WORD, U16Event) FreqTilt = (WORD + 5, U16Event) FXFlags = (WORD + 6, U16Event) Cutoff = (WORD + 7, U16Event) _VolWord = (WORD + 8, U16Event) _PanWord = (WORD + 9, U16Event) Preamp = (WORD + 10, U16Event) #: 1.2.12+ FadeOut = (WORD + 11, U16Event) #: 1.7.6+ FadeIn = (WORD + 12, U16Event) # _DotNote = WORD + 13 # _DotPitch = WORD + 14 # _DotMix = WORD + 15 Resonance = (WORD + 19, U16Event) # _LoopBar = WORD + 20 StereoDelay = (WORD + 21, U16Event) #: 1.3.56+ Pogo = (WORD + 22, U16Event) # _DotReso = WORD + 23 # _DotCutOff = WORD + 24 TimeShift = (WORD + 25, U16Event) # _Dot = WORD + 27 # _DotRel = WORD + 32 # _DotShift = WORD + 28 Children = (WORD + 30, U16Event) #: 3.4.0+ Swing = (WORD + 33, U16Event) # Echo = DWORD + 2 RingMod = (DWORD + 3, U16TupleEvent) CutGroup = (DWORD + 4, U16TupleEvent) RootNote = (DWORD + 7, U32Event) # _MainResoCutOff = DWORD + 9 DelayModXY = (DWORD + 10, U16TupleEvent) Reverb = (DWORD + 11, U32Event) #: 1.4.0+ _StretchTime = (DWORD + 12, F32Event) #: 5.0+ FineTune = (DWORD + 14, I32Event) SamplerFlags = (DWORD + 15, U32Event) LayerFlags = (DWORD + 16, U32Event) GroupNum = (DWORD + 17, I32Event) AUSampleRate = (DWORD + 25, U32Event) _Name = TEXT SamplePath = TEXT + 4 Delay = (DATA + 1, DelayEvent) Parameters = (DATA + 7, ParametersEvent) EnvelopeLFO = (DATA + 10, EnvelopeLFOEvent) Levels = (DATA + 11, LevelsEvent) # _Filter = DATA + 12 Polyphony = (DATA + 13, PolyphonyEvent) # _LegacyAutomation = DATA + 15 Tracking = (DATA + 20, TrackingEvent) LevelAdjusts = (DATA + 21, LevelAdjustsEvent) Automation = (DATA + 26, AutomationEvent) @enum.unique class DisplayGroupID(EventEnum): Name = TEXT + 39 #: 3.4.0+ @enum.unique class RackID(EventEnum): Swing = (11, U8Event) _FitToSteps = (13, U8Event) WindowHeight = (DWORD + 5, U32Event) @enum.unique class ReverbType(enum.IntEnum): """Used by :attr:`Reverb.type`.""" A = 0 B = 65536 # The type of a channel may decide how a certain event is interpreted. An # example of this is `ChannelID.Levels` event, which is used for storing # volume, pan and pich bend range of any channel other than automations. In # automations it is used for **Min** and **Max** knobs. @enum.unique class ChannelType(ct.EnumBase): # cuz Type would be a super generic name """An internal marker used to indicate the type of a channel.""" Sampler = 0 """Used exclusively for the inbuilt Sampler.""" Native = 2 """Used by audio clips and other native FL Studio synths.""" Layer = 3 # 3.4.0+ Instrument = 4 Automation = 5 # 5.0+ class _FXFlags(enum.IntFlag): FadeStereo = 1 << 0 Reverse = 1 << 1 Clip = 1 << 2 SwapStereo = 1 << 8 class _LayerFlags(enum.IntFlag): Random = 1 << 0 Crossfade = 1 << 1 class _SamplerFlags(enum.IntFlag): Resample = 1 << 0 LoadRegions = 1 << 1 LoadSliceMarkers = 1 << 2 UsesLoopPoints = 1 << 3 KeepOnDisk = 1 << 8 class DisplayGroup(EventModel, ModelReprMixin): def __str__(self) -> str: if self.name is None: return "Unnamed display group" return f"Display group {self.name}" name = EventProp[str](DisplayGroupID.Name) class Arp(EventModel, ModelReprMixin): """Used by :class:`Sampler`: and :class:`Instrument`. ![](https://bit.ly/3Lbk7Yi) """ chord = StructProp[int]() """Index of the selected arpeggio chord.""" direction = StructProp[ArpDirection]() gate = StructProp[float]() """Delay between two successive notes played.""" range = StructProp[int]() """Range (in octaves).""" repeat = StructProp[int]() """Number of times a note is repeated. *New in FL Studio v4.5.2*. """ slide = StructProp[bool]() """Whether arpeggio will slide between notes.""" time = StructProp[float]() """Delay between two successive notes played.""" class Delay(EventModel, ModelReprMixin): """Echo delay / fat mode section. Used by :class:`Sampler` and :class:`Instrument`. ![](https://bit.ly/3RyzbBD) """ echoes = StructProp[int](ChannelID.Delay) """Number of echoes generated for each note. Min = 1. Max = 10.""" fat_mode = FlagProp(_DelayFlags.FatMode, ChannelID.Parameters, prop="delay.flags") """*New in FL Studio v3.4.0*.""" feedback = StructProp[int](ChannelID.Delay) """Factor with which the volume of every next echo is multiplied. Defaults to minimum value. | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 0% | | Max | 25600 | 200% | """ @property def mod_x(self) -> int: """Min = 0. Max = 256. Default = 128.""" return self.events.first(ChannelID.DelayModXY).value[0] @mod_x.setter def mod_x(self, value: int) -> None: event = self.events.first(ChannelID.DelayModXY) event.value = (value, event.value[1]) @property def mod_y(self) -> int: """Min = 0. Max = 256. Default = 128.""" return self.events.first(ChannelID.DelayModXY).value[1] @mod_y.setter def mod_y(self, value: int) -> None: event = self.events.first(ChannelID.DelayModXY) event.value = (event.value[0], value) pan = StructProp[int](ChannelID.Delay) """ | Type | Value | Representation | |---------|-------|----------------| | Min | -6400 | 100% left | | Max | 6400 | 100% right | | Default | 0 | Centred | """ ping_pong = FlagProp( _DelayFlags.PingPong, ChannelID.Parameters, prop="delay.flags", ) """*New in FL Studio v1.7.6*.""" pitch_shift = StructProp[int](ChannelID.Delay) """Pitch shift (in cents). | Min | Max | Default | |-------|-------|---------| | -1200 | 1200 | 0 | """ time = StructProp[int](ChannelID.Delay) """Tempo-synced delay time. PPQ dependant. | Type | Value | Representation | |---------|-----------|----------------| | Min | 0 | 0:00 | | Max | PPQ * 4 | 8:00 | | Default | PPQ * 3/2 | 3:00 | """ class Filter(EventModel, ModelReprMixin): """Used by :class:`Sampler`. ![](https://bit.ly/3zT5tAH) """ mod_x = StructProp[int](ChannelID.Levels, prop="filter.mod_x") """Filter cutoff. Min = 0. Max = 256. Defaults to maximum.""" mod_y = StructProp[int](ChannelID.Levels, prop="filter.mod_y") """Filter resonance. Min = 0. Max = 256. Defaults to minimum.""" type = StructProp[FilterType](ChannelID.Levels, prop="filter.type") """Defaults to :attr:`FilterType.FastLP`.""" class LevelAdjusts(EventModel, ModelReprMixin): """Used by :class:`Layer`, :class:`Instrument` and :class:`Sampler`. ![](https://bit.ly/3xkKeGn) *New in FL Studio v3.3.0*. """ mod_x = StructProp[int]() mod_y = StructProp[int]() pan = StructProp[int]() volume = StructProp[int]() class Time(EventModel, ModelReprMixin): """Used by :class:`Sampler` and :class:`Instrument`. ![](https://bit.ly/3xjxUGG) """ swing = EventProp[int](ChannelID.Swing) """Percentage of the ``ChannelRack.swing`` that affects this channel. Linear. Min = 0. Max = 128. Defaults to maximum. """ gate = StructProp[int](ChannelID.Parameters, prop="time.gate") """Logarithmic. Defaults to disabled state. | Type | Value | Representation | |----------|-------|----------------| | Min | 450 | 0:03 | | Max | 1446 | 4:00 | | Disabled | 1447 | Off | """ shift = EventProp[int](ChannelID.TimeShift) """Fine time shift. Nonlinear. Defaults to minimum. | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 0:00 | | Max | 1024 | 1:00 | """ full_porta = StructProp[bool](ChannelID.Parameters, prop="time.full_porta") """Whether :attr:`gate` is bypassed when :attr:`Polyphony.porta` is on.""" class Reverb(EventModel, ModelReprMixin): """Precalculated reverb used by :class:`Sampler`. *New in FL Studio v1.4.0*. """ @property def type(self) -> ReverbType | None: if ChannelID.Reverb in self.events.ids: event = self.events.first(ChannelID.Reverb) return ReverbType.B if event.value >= ReverbType.B else ReverbType.A @type.setter def type(self, value: ReverbType) -> None: if self.mix is None: raise PropertyCannotBeSet(ChannelID.Reverb) self.events.first(ChannelID.Reverb).value = value.value + self.mix @property def mix(self) -> int | None: """Mix % (wet). Defaults to minimum value. | Min | Max | |-----|-----| | 0 | 256 | """ if ChannelID.Reverb in self.events.ids: return self.events.first(ChannelID.Reverb).value - self.type @mix.setter def mix(self, value: int) -> None: if ChannelID.Reverb not in self.events.ids: raise PropertyCannotBeSet(ChannelID.Reverb) self.events.first(ChannelID.Reverb).value += value class FX(EventModel, ModelReprMixin): """Pre-computed effects used by :class:`Sampler`. ![](https://bit.ly/3U3Ys8l) ![](https://bit.ly/3qvdBSN) See Also: :attr:`Sampler.fx`, :attr:`Reverb` """ boost = EventProp[int](ChannelID.Preamp) """Pre-amp gain. Defaults to minimum value. | Min | Max | |-----|-----| | 0 | 256 | *New in FL Studio v1.2.12*. """ clip = FlagProp(_FXFlags.Clip, ChannelID.FXFlags) """Whether output is clipped at 0dB for :attr:`boost`.""" crossfade = StructProp[int](ChannelID.Parameters, prop="fx.crossfade") """Linear. Defaults to minimum value | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 0% | | Max | 256 | 100% | """ cutoff = EventProp[int](ChannelID.Cutoff) """Filter Mod X. Defaults to maximum value. Min = 16. Max = 1024.""" fade_in = EventProp[int](ChannelID.FadeIn) """Quick fade-in. Defaults to minimum value. Min = 0. Max = 1024.""" fade_out = EventProp[int](ChannelID.FadeOut) """Quick fade-out. Defaults to minimum value. Min = 0. Max = 1024. *New in FL Studio v1.7.6*. """ fade_stereo = FlagProp(_FXFlags.FadeStereo, ChannelID.FXFlags) fix_trim = StructProp[bool](ChannelID.Parameters, prop="fx.fix_trim") """:menuselection:`Trim --> Fix legacy precomputed length`. Has no effect on the value of :attr:`trim`. """ freq_tilt = EventProp[int](ChannelID.FreqTilt) """Shifts the frequency balance. Bipolar. | Min | Max | Default | |-----|-----|---------| | 0 | 256 | 128 | """ inverted = StructProp[bool](ChannelID.Parameters, prop="fx.inverted") """Named :guilabel:`Reverse polarity` in FL's interface.""" length = StructProp[float](ChannelID.Parameters, prop="fx.length") """Min = 0.0, Max = 1.0. Defaults to minimum value. Named :guilabel:`SMP START` in FL's interface. """ normalize = StructProp[bool](ChannelID.Parameters, prop="fx.normalize") """Maximizes volume without clipping by normalizing peaks to 0dB.""" pogo = EventProp[int](ChannelID.Pogo) """Pitch bend effect. Bipolar. | Min | Max | Default | |-----|-----|---------| | 0 | 512 | 256 | """ remove_dc = StructProp[bool](ChannelID.Parameters, prop="fx.remove_dc") """Whether DC offset (if present) is removed. *New in FL Studio v2.5.0*. """ resonance = EventProp[int](ChannelID.Resonance) """Filter Mod Y. Min = 0. Max = 640. Defaults to minimum value.""" reverb = NestedProp[Reverb](Reverb, ChannelID.Reverb) reverse = FlagProp(_FXFlags.Reverse, ChannelID.FXFlags) """Whether sample is reversed or not.""" ringmod = EventProp[Tuple[int, int]](ChannelID.RingMod) """Ring modulation returned as a tuple of ``(mix, frequency)``. Limits for both: | Min | Max | Default | |-----|-----|---------| | 0 | 256 | 128 | """ start = StructProp[float](ChannelID.Parameters, prop="fx.start") """Min = 0.0, Max = 1.0. Defaults to minimum value. Always set to 0.0 irrespective of the knob position unless a sample is loaded. """ stereo_delay = EventProp[int](ChannelID.StereoDelay) """Linear. Bipolar. | Min | Max | Default | |-----|------|---------| | 0 | 4096 | 2048 | *New in FL Studio v1.3.56*. """ swap_stereo = FlagProp(_FXFlags.SwapStereo, ChannelID.FXFlags) """Whether left and right channels are swapped or not.""" trim = StructProp[int](ChannelID.Parameters, prop="fx.trim") """Silence trimming threshold. Defaults to minimum. Linear. | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 0% | | Max | 256 | 100% | """ class Envelope(EventModel, ModelReprMixin): """A PAHDSR envelope for various :class:`Sampler` paramters. ![](https://bit.ly/3d9WCCh) See Also: :attr:`Sampler.envelopes` *New in FL Studio v2.5.0*. """ enabled = StructProp[bool](prop="envelope.enabled") """Whether envelope section is enabled.""" predelay = StructProp[int](prop="envelope.predelay") """Linear. Defaults to minimum value. | Type | Value | Representation | |------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | """ amount = StructProp[int](prop="envelope.amount") """Linear. Bipolar. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | 0 | 0% | """ attack = StructProp[int](prop="envelope.attack") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | | Default | 20000 | 31% | """ hold = StructProp[int](prop="envelope.hold") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | | Default | 20000 | 31% | """ decay = StructProp[int](prop="envelope.decay") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | | Default | 30000 | 46% | """ sustain = StructProp[int](prop="envelope.sustain") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0% | | Max | 128 | 100% | | Default | 50 | 39% | """ release = StructProp[int](prop="envelope.release") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | | Default | 20000 | 31% | """ synced = FlagProp(_EnvLFOFlags.EnvelopeTempoSync) """Whether envelope is synced to tempo or not.""" attack_tension = StructProp[int](prop="envelope.attack_tension") """Linear. Bipolar. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | 0 | 0% | *New in FL Studio v3.5.4*. """ decay_tension = StructProp[int](prop="envelope.decay_tension") """Linear. Bipolar. | Type | Value | Mix (wet) | |---------|-------|-----------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | 0 | 0% | *New in FL Studio v3.5.4*. """ release_tension = StructProp[int](prop="envelope.release_tension") """Linear. Bipolar. | Type | Value | Mix (wet) | |---------|-------|-----------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | -101 | -79% | *New in FL Studio v3.5.4*. """ class SamplerLFO(EventModel, ModelReprMixin): """A basic LFO for certain :class:`Sampler` parameters. ![](https://bit.ly/3RG5Jtw) See Also: :attr:`Sampler.lfos` *New in FL Studio v2.5.0*. """ amount = StructProp[int](prop="lfo.amount") """Linear. Bipolar. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | 0 | 0% | """ attack = StructProp[int](prop="lfo.attack") """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | | Default | 20000 | 31% | """ predelay = StructProp[int](prop="lfo.predelay") """Linear. Defaults to minimum value. | Type | Value | Representation | |---------|-------|----------------| | Min | 100 | 0% | | Max | 65536 | 100% | """ speed = StructProp[int](prop="lfo.speed") """Logarithmic. Provides tempo synced options. | Type | Value | Representation | |---------|-------|----------------| | Min | 200 | 0% | | Max | 65536 | 100% | | Default | 32950 | 50% (16 steps) | """ synced = FlagProp(_EnvLFOFlags.LFOTempoSync) """Whether LFO is synced with tempo.""" retrig = FlagProp(_EnvLFOFlags.LFOPhaseRetrig) """Whether LFO phase is in global / retriggered mode.""" shape = StructProp[LFOShape](prop="lfo.shape") """Sine, triangle or pulse. Default: Sine.""" class Polyphony(EventModel, ModelReprMixin): """Used by :class:`Sampler` and :class:`Instrument`. ![](https://bit.ly/3DlvWcl) """ mono = FlagProp(_PolyphonyFlags.Mono) """Whether monophonic mode is enabled or not.""" porta = FlagProp(_PolyphonyFlags.Porta) """*New in FL Studio v3.3.0*.""" max = StructProp[int]() """Max number of voices.""" slide = StructProp[int]() """Portamento time. Nonlinear. | Type | Value | Representation | |---------|-------|-----------------| | Min | 0 | 0:00 | | Max | 1660 | 8:00 (8 steps) | | Default | 820 | 0:12 (1/2 step) | *New in FL Studio v3.3.0*. """ class Tracking(EventModel, ModelReprMixin): """Used by :class:`Sampler` and :class:`Instrument`. ![](https://bit.ly/3DmveM8) *New in FL Studio v3.3.0*. """ middle_value = StructProp[int]() """Note index. Min: C0 (0), Max: B10 (131).""" mod_x = StructProp[int]() """Bipolar. | Min | Max | Default | |------|-----|---------| | -256 | 256 | 0 | """ mod_y = StructProp[int]() """Bipolar. | Min | Max | Default | |------|-----|---------| | -256 | 256 | 0 | """ pan = StructProp[int]() """Linear. Bipolar. | Min | Max | Default | |------|-----|---------| | -256 | 256 | 0 | """ class Keyboard(EventModel, ModelReprMixin): """Used by :class:`Sampler` and :class:`Instrument`. ![](https://bit.ly/3qwIK8r) *New in FL Studio v1.3.56*. """ fine_tune = EventProp[int](ChannelID.FineTune) """-100 to +100 cents.""" # TODO Return this as a note name, like `Note.key` root_note = EventProp[int](ChannelID.RootNote, default=60) """Min - 0 (C0), Max - 131 (B10).""" main_pitch = StructProp[bool](ChannelID.Parameters, prop="keyboard.main_pitch") """Whether triggered note is affected by changes to :attr:`Project.main_pitch`.""" add_root = StructProp[bool](ChannelID.Parameters, prop="keyboard.add_root") """Whether to add root note (instead of pitch) to triggered note. Named as :guilabel:`Add to key`. Defaults to ``False``. *New in FL Studio v3.4.0*. """ key_region = StructProp[Tuple[int, int]](ChannelID.Parameters, prop="keyboard.key_region") """A `(start_note, end_note)` tuple representing the playable range.""" class Playback(EventModel, ModelReprMixin): """Used by :class:`Sampler`. ![](https://bit.ly/3xjSypY) """ ping_pong_loop = EventProp[bool](ChannelID.PingPongLoop) start_offset = StructProp[int](ChannelID.Parameters, prop="playback.start_offset") """Linear. Defaults to minimum value. | Type | Value | Representation | |------|------------|----------------| | Min | 0 | 0% | | Max | 1072693248 | 100% | """ use_loop_points = FlagProp(_SamplerFlags.UsesLoopPoints, ChannelID.SamplerFlags) class TimeStretching(EventModel, ModelReprMixin): """Used by :class:`Sampler`. ![](https://bit.ly/3eIAjnG) *New in FL Studio v5.0*. """ mode = StructProp[StretchMode](ChannelID.Parameters, prop="stretching.mode") multiplier = StructProp[float](ChannelID.Parameters, prop="stretching.multiplier") """Logarithmic. Bipolar. | Type | Value | Representation | |---------|-------|----------------| | Min | 0.25 | 25% | | Max | 4.0 | 400% | | Default | 0 | 100% | """ pitch = StructProp[int](ChannelID.Parameters, prop="stretching.pitch") """Pitch shift (in cents). Min = -1200. Max = 1200. Defaults to 0.""" time = StructProp[MusicalTime](ChannelID.Parameters, prop="stretching.time") """Returns a tuple of ``(bars, beats, ticks)``.""" class Content(EventModel, ModelReprMixin): """Used by :class:`Sampler`. ![](https://bit.ly/3TCXFKI) """ declick_mode = StructProp[DeclickMode](ChannelID.Parameters, prop="content.declick_mode") """Defaults to ``DeclickMode.OutOnly``. *New in FL Studio v9.0.0*. """ keep_on_disk = FlagProp(_SamplerFlags.KeepOnDisk, ChannelID.SamplerFlags) """Whether a sample is streamed from disk or kept in RAM, defaults to ``False``. *New in FL Studio v2.5.0*. """ load_regions = FlagProp(_SamplerFlags.LoadRegions, ChannelID.SamplerFlags) """Load regions found in the sample, if any, defaults to ``True``.""" load_slices = FlagProp(_SamplerFlags.LoadSliceMarkers, ChannelID.SamplerFlags) """Defaults to ``False``.""" resample = FlagProp(_SamplerFlags.Resample, ChannelID.SamplerFlags) """Defaults to ``False``. *New in FL Studio v2.5.0*. """ class AutomationLFO(EventModel, ModelReprMixin): amount = StructProp[int](ChannelID.Automation, prop="lfo.amount") """Linear. Bipolar. | Type | Value | Representation | |---------|------------|----------------| | Min | -128 | -100% | | Max | 128 | 100% | | Default | 64 or 0 | 50% or 0% | """ class AutomationPoint(ItemModel[AutomationEvent], ModelReprMixin): def __setitem__(self, prop: str, value: Any) -> None: self._item[prop] = value self._parent["points"][self._index] = self._item position = StructProp[int](readonly=True) """PPQ dependant. Position on X-axis. This property cannot be set as of yet. """ tension = StructProp[float]() """A value in the range of 0 to 1.0.""" value = StructProp[float]() """Position on Y-axis in the range of 0 to 1.0.""" class Channel(EventModel): """Represents a channel in the channel rack.""" def __repr__(self) -> str: return f"{type(self).__name__} (name={self.display_name!r}, iid={self.iid})" color = EventProp[RGBA](PluginID.Color) """Defaults to #5C656A (granite gray). ![](https://bit.ly/3SllDsG) Values below 20 for any color component (R, G or B) are ignored by FL. """ # TODO controllers = KWProp[List[RemoteController]]() internal_name = EventProp[str](PluginID.InternalName) """Internal name of the channel. The value of this depends on the type of `plugin`: * Native (stock) plugin: Empty *afaik*. * VST instruments: "Fruity Wrapper". See Also: :attr:`name` """ enabled = EventProp[bool](ChannelID.IsEnabled) """![](https://bit.ly/3sbN8KU)""" @property def group(self) -> DisplayGroup: # TODO Setter """Display group / filter under which this channel is grouped.""" return self._kw["group"] icon = EventProp[int](PluginID.Icon) """Internal ID of the icon shown beside the ``display_name``. ![](https://bit.ly/3zjK2sf) """ iid = EventProp[int](ChannelID.New) keyboard = NestedProp(Keyboard, ChannelID.FineTune, ChannelID.RootNote, ChannelID.Parameters) """Located at the bottom of :menuselection:`Miscellaneous functions (page)`.""" locked = EventProp[bool](ChannelID.IsLocked) """Whether in a locked state or not; mute / solo acts differently when ``True``. ![](https://bit.ly/3BOBc7j) """ name = EventProp[str](PluginID.Name, ChannelID._Name) """The name associated with a channel. It's value depends on the type of plugin: * Native (stock): User-given name, None if not given one. * VST instrument: The name obtained from the VST or the user-given name. See Also: :attr:`internal_name` and :attr:`display_name`. """ @property def pan(self) -> int | None: """Linear. Bipolar. | Min | Max | Default | |-----|-------|---------| | 0 | 12800 | 6400 | """ if ChannelID.Levels in self.events.ids: return cast(LevelsEvent, self.events.first(ChannelID.Levels))["pan"] for id in (ChannelID._PanWord, ChannelID._PanByte): if id in self.events.ids: return self.events.first(id).value @pan.setter def pan(self, value: int) -> None: if self.pan is None: raise PropertyCannotBeSet if ChannelID.Levels in self.events.ids: cast(LevelsEvent, self.events.first(ChannelID.Levels))["pan"] = value return for id in (ChannelID._PanWord, ChannelID._PanByte): if id in self.events.ids: self.events.first(id).value = value @property def volume(self) -> int | None: """Nonlinear. | Min | Max | Default | |-----|-------|---------| | 0 | 12800 | 10000 | """ if ChannelID.Levels in self.events.ids: return cast(LevelsEvent, self.events.first(ChannelID.Levels))["volume"] for id in (ChannelID._VolWord, ChannelID._VolByte): if id in self.events.ids: return self.events.first(id).value @volume.setter def volume(self, value: int) -> None: if self.volume is None: raise PropertyCannotBeSet if ChannelID.Levels in self.events.ids: cast(LevelsEvent, self.events.first(ChannelID.Levels))["volume"] = value return for id in (ChannelID._VolWord, ChannelID._VolByte): if id in self.events.ids: self.events.first(id).value = value # If the channel is not zipped, underlying event is not stored. @property def zipped(self) -> bool: """Whether the channel is zipped / minimized. ![](https://bit.ly/3S2imib) """ if ChannelID.Zipped in self.events.ids: return self.events.first(ChannelID.Zipped).value return False @property def display_name(self) -> str | None: """The name of the channel that will be displayed in FL Studio.""" return self.name or self.internal_name # type: ignore class Automation(Channel, ModelCollection[AutomationPoint]): """Represents an automation clip present in the channel rack. Iterate to get the :attr:`points` inside the clip. >>> repr([point for point in automation]) AutomationPoint(position=0.0, value=1.0, tension=0.5), ... ![](https://bit.ly/3RXQhIN) """ @supports_slice # type: ignore def __getitem__(self, i: int | slice) -> AutomationPoint: for idx, p in enumerate(self): if idx == i: return p raise ModelNotFound(i) def __iter__(self) -> Iterator[AutomationPoint]: """Iterator over the automation points inside the automation clip.""" if ChannelID.Automation in self.events.ids: event = cast(AutomationEvent, self.events.first(ChannelID.Automation)) for i, point in enumerate(event["points"]): yield AutomationPoint(point, i, event) lfo = NestedProp(AutomationLFO, ChannelID.Automation) # TODO Add image class Layer(Channel, ModelCollection[Channel]): """Represents a layer channel present in the channel rack. ![](https://bit.ly/3S2MLgf) *New in FL Studio v3.4.0*. """ @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Channel: """Returns a child :class:`Channel` with an IID of :attr:`Channel.iid`. Args: i: IID or 0-based index of the child(ren). Raises: ChannelNotFound: Child(ren) with the specific index or IID couldn't be found. This exception derives from ``KeyError`` as well. """ for child in self: if i == child.iid: return child raise ChannelNotFound(i) def __iter__(self) -> Iterator[Channel]: if ChannelID.Children in self.events.ids: for event in self.events.get(ChannelID.Children): yield self._kw["channels"][event.value] def __len__(self) -> int: """Returns the number of channels whose parent this layer is.""" try: return self.events.count(ChannelID.Children) except KeyError: return 0 def __repr__(self) -> str: return f"{super().__repr__()[:-1]}, {len(self)} children)" crossfade = FlagProp(_LayerFlags.Crossfade, ChannelID.LayerFlags) """:menuselection:`Miscellaneous functions --> Layering`""" random = FlagProp(_LayerFlags.Random, ChannelID.LayerFlags) """:menuselection:`Miscellaneous functions --> Layering`""" class _SamplerInstrument(Channel): arp = NestedProp(Arp, ChannelID.Parameters) """:menuselection:`Miscellaneous functions -> Arpeggiator`""" cut_group = EventProp[Tuple[int, int]](ChannelID.CutGroup) """Cut group in the form of (Cut self, cut by). :menuselection:`Miscellaneous functions --> Group` Hint: To cut itself when retriggered, set the same value for both. """ delay = NestedProp(Delay, ChannelID.Delay, ChannelID.DelayModXY, ChannelID.Parameters) """:menuselection:`Miscellaneous functions -> Echo delay / fat mode`""" insert = EventProp[int](ChannelID.RoutedTo) """The index of the :class:`Insert` the channel is routed to according to FL. "Current" insert = -1, Master = 0 and so on... till :attr:`Mixer.max_inserts`. """ level_adjusts = NestedProp(LevelAdjusts, ChannelID.LevelAdjusts) """:menuselection:`Miscellaneous functions -> Level adjustments`""" @property def pitch_shift(self) -> int | None: """-4800 to +4800 (cents). Raises: PropertyCannotBeSet: When a `ChannelID.Levels` event is not found. """ if ChannelID.Levels in self.events.ids: return cast(LevelsEvent, self.events.first(ChannelID.Levels))["pitch_shift"] @pitch_shift.setter def pitch_shift(self, value: int) -> None: try: event = self.events.first(ChannelID.Levels) except KeyError as exc: raise PropertyCannotBeSet(ChannelID.Levels) from exc else: cast(LevelsEvent, event)["pitch_shift"] = value polyphony = NestedProp(Polyphony, ChannelID.Polyphony) """:menuselection:`Miscellaneous functions -> Polyphony`""" time = NestedProp(Time, ChannelID.Swing, ChannelID.TimeShift, ChannelID.Parameters) """:menuselection:`Miscellaneous functions -> Time`""" @property def tracking(self) -> dict[str, Tracking] | None: """A :class:`Tracking` each for Volume & Keyboard. :menuselection:`Miscellaneous functions -> Tracking` """ if ChannelID.Tracking in self.events.ids: tracking = [Tracking(e) for e in self.events.separate(ChannelID.Tracking)] return dict(zip(("volume", "keyboard"), tracking)) class Instrument(_SamplerInstrument): """Represents a native or a 3rd party plugin loaded in a channel.""" plugin = PluginProp(VSTPlugin, BooBass, FruitKick, Plucked) """The plugin loaded into the channel.""" # TODO New in FL Studio v1.4.0 & v1.5.23: Sampler spectrum views class Sampler(_SamplerInstrument): """Represents the native Sampler, either as a clip or a channel. ![](https://bit.ly/3DlHPiI) """ def __repr__(self) -> str: return f"{super().__repr__()[:-1]}, sample_path={self.sample_path!r})" au_sample_rate = EventProp[int](ChannelID.AUSampleRate) """AU-format sample specific.""" content = NestedProp(Content, ChannelID.SamplerFlags, ChannelID.Parameters) """:menuselection:`Sample settings --> Content`""" # FL's interface doesn't have an envelope for panning, but still stores # the default values in event data. @property def envelopes(self) -> dict[EnvelopeName, Envelope] | None: """An :class:`Envelope` each for Volume, Panning, Mod X, Mod Y and Pitch. :menuselection:`Envelope / instruement settings` """ if ChannelID.EnvelopeLFO in self.events.ids: envs = [Envelope(e) for e in self.events.separate(ChannelID.EnvelopeLFO)] return dict(zip(EnvelopeName.__args__, envs)) # type: ignore filter = NestedProp(Filter, ChannelID.Levels) fx = NestedProp( FX, ChannelID.Cutoff, ChannelID.FadeIn, ChannelID.FadeOut, ChannelID.FreqTilt, ChannelID.Parameters, ChannelID.Pogo, ChannelID.Preamp, ChannelID.Resonance, ChannelID.Reverb, ChannelID.RingMod, ChannelID.StereoDelay, ChannelID.FXFlags, ) """:menuselection:`Sample settings (page) --> Precomputed effects`""" @property def lfos(self) -> dict[LFOName, SamplerLFO] | None: """An :class:`LFO` each for Volume, Panning, Mod X, Mod Y and Pitch. :menuselection:`Envelope / instruement settings (page)` """ if ChannelID.EnvelopeLFO in self.events.ids: lfos = [SamplerLFO(e) for e in self.events.separate(ChannelID.EnvelopeLFO)] return dict(zip(LFOName.__args__, lfos)) # type: ignore playback = NestedProp( Playback, ChannelID.SamplerFlags, ChannelID.PingPongLoop, ChannelID.Parameters ) """:menuselection:`Sample settings (page) --> Playback`""" @property def sample_path(self) -> pathlib.Path | None: """Absolute path of a sample file on the disk. :menuselection:`Sample settings (page) --> File` Contains the string ``%FLStudioFactoryData%`` for stock samples. """ if ChannelID.SamplePath in self.events.ids: return pathlib.Path(self.events.first(ChannelID.SamplePath).value) @sample_path.setter def sample_path(self, value: pathlib.Path) -> None: if self.sample_path is None: raise PropertyCannotBeSet(ChannelID.SamplePath) path = "" if str(value) == "." else str(value) self.events.first(ChannelID.SamplePath).value = path # TODO Find whether ChannelID._StretchTime was really used for attr ``time``. stretching = NestedProp(TimeStretching, ChannelID.Parameters) """:menuselection:`Sample settings (page) --> Time stretching`""" class ChannelRack(EventModel, ModelCollection[Channel]): """Represents the channel rack, contains all :class:`Channel` instances. ![](https://bit.ly/3RXR50h) """ def __repr__(self) -> str: return f"ChannelRack - {len(self)} channels" @supports_slice # type: ignore def __getitem__(self, i: str | int | slice) -> Channel: """Gets a channel from the rack based on its IID or name. Args: i: Compared with :attr:`Channel.iid` if an int or slice or with the :attr:`Channel.display_name`. Raises: ChannelNotFound: A channel with the specified IID or name isn't found. """ for ch in self: if (isinstance(i, int) and i == ch.iid) or (i == ch.display_name): return ch raise ChannelNotFound(i) def __iter__(self) -> Iterator[Channel]: """Yields all the channels found in the project.""" ch_dict: dict[int, Channel] = {} groups = [DisplayGroup(et) for et in self.events.separate(DisplayGroupID.Name)] for et in self.events.divide(ChannelID.New, *ChannelID, *PluginID): iid = et.first(ChannelID.New).value typ = et.first(ChannelID.Type).value groupnum = et.first(ChannelID.GroupNum).value ct = Channel # prevent type error and logic failure below if typ == ChannelType.Automation: ct = Automation elif typ == ChannelType.Layer: ct = Layer elif typ == ChannelType.Sampler: ct = Sampler elif typ in (ChannelType.Instrument, ChannelType.Native): ct = Instrument # Audio clips are stored as Instrument until a sample is loaded in them if all(id in et for id in (ChannelID.SamplePath, PluginID.InternalName)): if not et.first(PluginID.InternalName).value and ct == Instrument: ct = Sampler if iid is not None: cur_ch = ch_dict[iid] = ct(et, channels=ch_dict, group=groups[groupnum]) yield cur_ch def __len__(self) -> int: """Returns the number of channels found in the project. Raises: NoModelsFound: No channels could be found in the project. """ if ChannelID.New not in self.events.ids: raise NoModelsFound return self.events.count(ChannelID.New) @property def automations(self) -> Iterator[Automation]: """Yields automation clips in the project.""" yield from (ch for ch in self if isinstance(ch, Automation)) # TODO Find out what this meant fit_to_steps = EventProp[int](RackID._FitToSteps) @property def groups(self) -> Iterator[DisplayGroup]: for ed in self.events.separate(DisplayGroupID.Name): yield DisplayGroup(ed) height = EventProp[int](RackID.WindowHeight) """Window height of the channel rack in the interface (in pixels).""" @property def instruments(self) -> Iterator[Instrument]: """Yields native and 3rd-party synth channels in the project.""" yield from (ch for ch in self if isinstance(ch, Instrument)) @property def layers(self) -> Iterator[Layer]: """Yields ``Layer`` channels in the project.""" yield from (ch for ch in self if isinstance(ch, Layer)) @property def samplers(self) -> Iterator[Sampler]: """Yields samplers and audio clips in the project.""" yield from (ch for ch in self if isinstance(ch, Sampler)) swing = EventProp[int](RackID.Swing) """Global channel swing mix. Linear. Defaults to minimum value. | Type | Value | Mix (wet) | |------|-------|-----------| | Min | 0 | 0% | | Max | 128 | 100% | """ ================================================ FILE: pyflp/controller.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by MIDI and remote ("internal") controllers.""" from __future__ import annotations import enum from typing import cast import construct as c from pyflp._events import DATA, EventEnum, StructEventBase from pyflp._models import EventModel, ModelReprMixin __all__ = ["RemoteController"] class MIDIControllerEvent(StructEventBase): STRUCT = c.Struct("_u1" / c.GreedyBytes) class RemoteControllerEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.Optional(c.Bytes(2)), # 2 "_u2" / c.Optional(c.Byte), # 3 "_u3" / c.Optional(c.Byte), # 4 "parameter_data" / c.Optional(c.Int16ul), # 6 "destination_data" / c.Optional(c.Int16sl), # 8 "_u4" / c.Optional(c.Bytes(8)), # 16 "_u5" / c.Optional(c.Bytes(4)), # 20 ).compile() @enum.unique class ControllerID(EventEnum): MIDI = (DATA + 18, MIDIControllerEvent) Remote = (DATA + 19, RemoteControllerEvent) class RemoteController(EventModel, ModelReprMixin): """![](https://bit.ly/3S0i4Zf) *New in FL Studio v3.3.0*. """ @property def parameter(self) -> int | None: """The ID of the plugin parameter to which controller is linked to.""" if ( value := cast(StructEventBase, self.events.first(ControllerID.Remote))["parameter_data"] is not None ): return value & 0x7FFF @property def controls_vst(self) -> bool | None: """Whether `parameter` is linked to a VST plugin. None when linked to a plugin parameter on an insert slot. """ if ( value := cast(StructEventBase, self.events.first(ControllerID.Remote))["parameter_data"] is not None ): return (value & 0x8000) > 0 ================================================ FILE: pyflp/exceptions.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the exceptions used by and shared across PyFLP.""" from __future__ import annotations import enum __all__ = [ "Error", "NoModelsFound", "EventIDOutOfRange", "InvalidEventChunkSize", "PropertyCannotBeSet", "HeaderCorrupted", "VersionNotDetected", "ModelNotFound", "DataCorrupted", ] class Error(Exception): """Base class for PyFLP exceptions. It is not guaranteed that exceptions raised from PyFLP always subclass Error. This is done to prevent duplication of exceptions. All exceptions raised by a function (in its body) explicitly are documented. Some exceptions derive from standard Python exceptions to ease handling. """ class EventIDOutOfRange(Error, ValueError): """An event is created with an ID out of its allowed range.""" def __init__(self, id: int, *expected: int) -> None: super().__init__(f"Expected ID in {expected!r}; got {id!r} instead") class InvalidEventChunkSize(Error, BufferError): """A fixed size event is created with a wrong amount of bytes.""" def __init__(self, expected: int, got: int) -> None: super().__init__(f"Expected a bytes object of length {expected}; got {got}") class PropertyCannotBeSet(Error, AttributeError): def __init__(self, *ids: enum.Enum | int) -> None: super().__init__(f"Event(s) {ids!r} was / were not found") class DataCorrupted(Error): """Base class for parsing exceptions.""" class HeaderCorrupted(DataCorrupted, ValueError): """Header chunk contains an unexpected / invalid value. Args: desc: A string containing details about what is corrupted. """ def __init__(self, desc: str) -> None: super().__init__(f"Error parsing header: {desc}") class NoModelsFound(DataCorrupted, LookupError): """Model's `__iter__` method fails to generate any model.""" class ModelNotFound(DataCorrupted, IndexError): """An invalid index is passed to model's `__getitem__` method.""" class VersionNotDetected(DataCorrupted): """String decoder couldn't be decided due to absence of project version.""" ================================================ FILE: pyflp/mixer.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by the mixer, inserts and effect slots.""" from __future__ import annotations import dataclasses import enum from collections import defaultdict from typing import Any, DefaultDict, Iterator, NamedTuple, cast import construct as c import construct_typed as ct from typing_extensions import NotRequired, TypedDict, Unpack from pyflp._adapters import StdEnum from pyflp._descriptors import EventProp, FlagProp, NamedPropMixin, ROProperty, RWProperty from pyflp._events import ( DATA, DWORD, TEXT, WORD, AnyEvent, ColorEvent, EventEnum, EventTree, I16Event, I32Event, ListEventBase, StructEventBase, U16Event, ) from pyflp._models import EventModel, ModelBase, ModelCollection, ModelReprMixin, supports_slice from pyflp.exceptions import ModelNotFound, NoModelsFound, PropertyCannotBeSet from pyflp.plugin import ( FruityBalance, FruityBloodOverdrive, FruityCenter, FruityFastDist, FruityNotebook2, FruitySend, FruitySoftClipper, FruityStereoEnhancer, PluginID, PluginProp, Soundgoodizer, VSTPlugin, ) from pyflp.types import RGBA, FLVersion, T __all__ = ["Insert", "InsertDock", "InsertEQ", "InsertEQBand", "Mixer", "Slot"] @enum.unique class _InsertFlags(enum.IntFlag): None_ = 0 PolarityReversed = 1 << 0 SwapLeftRight = 1 << 1 EnableEffects = 1 << 2 Enabled = 1 << 3 DisableThreadedProcessing = 1 << 4 U5 = 1 << 5 DockMiddle = 1 << 6 DockRight = 1 << 7 U8 = 1 << 8 U9 = 1 << 9 SeparatorShown = 1 << 10 Locked = 1 << 11 Solo = 1 << 12 U13 = 1 << 13 U14 = 1 << 14 AudioTrack = 1 << 15 # Whether insert is linked to an audio track @enum.unique class _MixerParamsID(ct.EnumBase): SlotEnabled = 0 SlotMix = 1 RouteVolStart = 64 # 64 - 191 are send level events Volume = 192 Pan = 193 StereoSeparation = 194 LowGain = 208 MidGain = 209 HighGain = 210 LowFreq = 216 MidFreq = 217 HighFreq = 218 LowQ = 224 MidQ = 225 HighQ = 226 class InsertFlagsEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.Optional(c.Bytes(4)), # 4 "flags" / c.Optional(StdEnum[_InsertFlags](c.Int32ul)), # 8 "_u2" / c.Optional(c.Bytes(4)), # 12 ).compile() class InsertRoutingEvent(ListEventBase): STRUCT = c.GreedyRange(c.Flag) @dataclasses.dataclass class _InsertItems: slots: DefaultDict[int, dict[int, dict[str, Any]]] = dataclasses.field( default_factory=lambda: defaultdict(dict) ) own: dict[int, dict[str, Any]] = dataclasses.field(default_factory=dict) class MixerParamsEvent(ListEventBase): STRUCT = c.GreedyRange( c.Struct( "_u4" / c.Bytes(4), # 4 "id" / StdEnum[_MixerParamsID](c.Byte), # 5 "_u1" / c.Byte, # 6 "channel_data" / c.Int16ul, # 8 "msg" / c.Int32sl, # 12 ) ) def __init__(self, id: Any, data: bytearray) -> None: super().__init__(id, data) self.items_: DefaultDict[int, _InsertItems] = defaultdict(_InsertItems) for item in self.data: insert_idx = (item["channel_data"] >> 6) & 0x7F slot_idx = item["channel_data"] & 0x3F insert = self.items_[insert_idx] id = item["id"] if id in (_MixerParamsID.SlotEnabled, _MixerParamsID.SlotMix): insert.slots[slot_idx][id] = item else: insert.own[id] = item @enum.unique class InsertID(EventEnum): Icon = (WORD + 31, I16Event) Output = (DWORD + 19, I32Event) Color = (DWORD + 21, ColorEvent) #: 4.0+ Input = (DWORD + 26, I32Event) Name = TEXT + 12 #: 3.5.4+ Routing = (DATA + 27, InsertRoutingEvent) Flags = (DATA + 28, InsertFlagsEvent) @enum.unique class MixerID(EventEnum): APDC = 29 Params = (DATA + 17, MixerParamsEvent) @enum.unique class SlotID(EventEnum): Index = (WORD + 34, U16Event) # ? Maybe added in FL Studio v6.0.1 class InsertDock(enum.Enum): """![](https://bit.ly/3eLum9D) See Also: :attr:`Insert.dock` """ # noqa Left = enum.auto() Middle = enum.auto() Right = enum.auto() class _InsertEQBandKW(TypedDict, total=False): gain: dict[str, Any] freq: dict[str, Any] reso: dict[str, Any] class _InsertEQBandProp(NamedPropMixin, RWProperty[int]): def __get__(self, ins: InsertEQBand, owner: Any = None) -> int | None: if owner is None: return NotImplemented return ins._kw[self._prop]["msg"] def __set__(self, ins: InsertEQBand, value: int) -> None: ins._kw[self._prop]["msg"] = value class InsertEQBand(ModelBase, ModelReprMixin): def __init__(self, **kw: Unpack[_InsertEQBandKW]) -> None: super().__init__(**kw) @property def size(self) -> int: return 12 * len(self._kw) # ! TODO gain = _InsertEQBandProp() """ | Min | Max | Default | |-------|------|---------| | -1800 | 1800 | 0 | """ freq = _InsertEQBandProp() """Nonlinear. Default depends on band e.g. ``InsertEQ.low``. | Type | Value | Representation | |------|-------|----------------| | Min | 0 | 10 Hz | | Max | 65536 | 16 kHz | """ reso = _InsertEQBandProp() """ | Min | Max | Default | |-----|-------|---------| | 0 | 65536 | 17500 | """ class _InsertEQPropArgs(NamedTuple): freq: int gain: int reso: int class _InsertEQProp(NamedPropMixin, ROProperty[InsertEQBand]): def __init__(self, ids: _InsertEQPropArgs) -> None: super().__init__() self._ids = ids def __get__(self, ins: InsertEQ, owner: Any = None) -> InsertEQBand: if owner is None: return NotImplemented items: _InsertEQBandKW = {} for id, param in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._ids.freq: items["freq"] = param elif id == self._ids.gain: items["gain"] = param elif id == self._ids.reso: items["reso"] = param return InsertEQBand(**items) # Stored in MixerID.Params event. class InsertEQ(ModelBase, ModelReprMixin): """Post-effect :class:`Insert` EQ with 3 adjustable bands. ![](https://bit.ly/3RUCQt6) See Also: :attr:`Insert.eq` """ def __init__(self, params: _InsertItems) -> None: super().__init__(params=params) @property def size(self) -> int: return 12 * self._kw["param"] # ! TODO low = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.LowFreq, _MixerParamsID.LowGain, _MixerParamsID.LowQ) ) """Low shelf band. Default frequency - 5777 (90 Hz).""" mid = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.MidFreq, _MixerParamsID.MidGain, _MixerParamsID.MidQ) ) """Middle band. Default frequency - 33145 (1500 Hz).""" high = _InsertEQProp( _InsertEQPropArgs(_MixerParamsID.HighFreq, _MixerParamsID.HighGain, _MixerParamsID.HighQ) ) """High shelf band. Default frequency - 55825 (8000 Hz).""" class _MixerParamProp(RWProperty[T]): def __init__(self, id: int) -> None: self._id = id def __get__(self, ins: Insert, owner: object = None) -> T | None: if owner is None: return NotImplemented for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._id: return item["msg"] def __set__(self, ins: Insert, value: T) -> None: for id, item in cast(_InsertItems, ins._kw["params"]).own.items(): if id == self._id: item["msg"] = value return raise PropertyCannotBeSet(self._id) class Slot(EventModel): """Represents an effect slot in an `Insert` / mixer channel. ![](https://bit.ly/3RUDtTu) """ def __init__(self, events: EventTree, params: list[dict[str, Any]] | None = None) -> None: super().__init__(events, params=params or []) def __repr__(self) -> str: return f"Slot (name={self.name}, iid={self.index}, plugin={self.plugin!r})" color = EventProp[RGBA](PluginID.Color) # TODO controllers = KWProp[List[RemoteController]]() iid = EventProp[int](SlotID.Index) """A 0-based internal index.""" internal_name = EventProp[str](PluginID.InternalName) """'Fruity Wrapper' for VST/AU plugins or factory name for native plugins.""" enabled = _MixerParamProp[bool](_MixerParamsID.SlotEnabled) """![](https://bit.ly/3eN4Ile)""" icon = EventProp[int](PluginID.Icon) index = EventProp[int](SlotID.Index) mix = _MixerParamProp[int](_MixerParamsID.SlotMix) """Dry/Wet mix. Defaults to maximum value. | Type | Value | Representation | |---------|-------|----------------| | Min | -6400 | 100% left | | Max | 6400 | 100% right | | Default | 0 | Centred | """ name = EventProp[str](PluginID.Name) plugin = PluginProp( VSTPlugin, FruityBalance, FruityBloodOverdrive, FruityCenter, FruityFastDist, FruityNotebook2, FruitySend, FruitySoftClipper, FruityStereoEnhancer, Soundgoodizer, ) """The effect loaded into the slot.""" class _InsertKW(TypedDict): iid: int max_slots: int params: NotRequired[_InsertItems] # TODO Need to make a `load()` method which will be able to parse preset files # (by looking at Project.format) and use `MixerParameterEvent.items` to get # remaining data. Normally, the `Mixer` passes this information to the Inserts # (and Inserts to the `Slot`s directly). class Insert(EventModel, ModelCollection[Slot]): """Represents a mixer track to which channel from the rack are routed to. ![](https://bit.ly/3LeGKuN) """ def __init__(self, events: EventTree, **kw: Unpack[_InsertKW]) -> None: super().__init__(events, **kw) # TODO Add number of used slots def __repr__(self) -> str: return f"Insert(name={self.name!r}, iid={self.iid})" @supports_slice # type: ignore def __getitem__(self, i: int | str) -> Slot: """Returns an effect slot of the specified index or name. Args: i: An index in the range of 0 to :attr:`Mixer.max_slots` or the name of the :class:`Slot`. Raises: ModelNotFound: An effect :class:`Slot` with the specified index or name isn't found. """ for idx, slot in enumerate(self): if (isinstance(i, int) and idx == i) or i == slot.name: return slot raise ModelNotFound(i) @property def iid(self) -> int: """-1 for "current" insert, 0 for master and upto :attr:`Mixer.max_inserts`.""" return self._kw["iid"] def __iter__(self) -> Iterator[Slot]: """Iterator over the effect empty and used slots.""" for idx, ed in enumerate(self.events.divide(SlotID.Index, *SlotID, *PluginID)): yield Slot(ed, params=self._kw["params"].slots[idx]) def __len__(self) -> int: try: return self.events.count(SlotID.Index) except KeyError: return len(list(self)) bypassed = FlagProp(_InsertFlags.EnableEffects, InsertID.Flags, inverted=True) """Whether all slots are bypassed.""" channels_swapped = FlagProp(_InsertFlags.SwapLeftRight, InsertID.Flags) """Whether the left and right channels are swapped.""" color = EventProp[RGBA](InsertID.Color) """Defaults to #636C71 (granite gray) in FL Studio. ![](https://bit.ly/3yVKXPc) Values below 20 for any color component (R, G, B) are ignored by FL. *New in FL Studio v4.0*. """ @property def dock(self) -> InsertDock | None: """The position (left, middle or right) where insert is docked in mixer. :menuselection:`Insert --> Layout --> Dock to` ![](https://bit.ly/3eLum9D) """ try: event = cast(InsertFlagsEvent, self.events.first(InsertID.Flags)) except KeyError: return None flags = _InsertFlags(event["flags"]) if _InsertFlags.DockMiddle in flags: return InsertDock.Middle if _InsertFlags.DockRight in flags: return InsertDock.Right return InsertDock.Left enabled = FlagProp(_InsertFlags.Enabled, InsertID.Flags) """Whether an insert in the mixer is enabled or disabled. ![](https://bit.ly/3BoRBOj) """ @property def eq(self) -> InsertEQ: """3-band post EQ. ![](https://bit.ly/3RUCQt6) """ return InsertEQ(self._kw["params"]) icon = EventProp[int](InsertID.Icon) """Internal ID of the icon shown beside ``name``. ![](https://bit.ly/3Slr6jc) """ input = EventProp[int](InsertID.Input) """![](https://bit.ly/3RO0ckC)""" is_solo = FlagProp(_InsertFlags.Solo, InsertID.Flags) """Whether the insert is solo'd.""" locked = FlagProp(_InsertFlags.Locked, InsertID.Flags) """Whether an insert in the mixer is in locked state. ![](https://bit.ly/3SdPbc2) """ name = EventProp[str](InsertID.Name) """*New in FL Studio v3.5.4*.""" output = EventProp[int](InsertID.Output) """![](https://bit.ly/3LjWjBD)""" pan = _MixerParamProp[int](_MixerParamsID.Pan) """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -6400 | 100% left | | Max | 6400 | 100% right | | Default | 0 | Centred | ![](https://bit.ly/3DsZRj4) """ polarity_reversed = FlagProp(_InsertFlags.PolarityReversed, InsertID.Flags) """Whether phase / polarity is reversed / inverted.""" @property def routes(self) -> Iterator[int]: """Send volumes to routed inserts. *New in FL Studio v4.0*. """ items = iter(cast(InsertRoutingEvent, self.events.first(InsertID.Routing))) for id, item in cast(_InsertItems, self._kw["params"]).own.items(): if id >= _MixerParamsID.RouteVolStart: try: cond = next(items) except StopIteration: continue else: if cond: yield item["msg"] separator_shown = FlagProp(_InsertFlags.SeparatorShown, InsertID.Flags) """Whether separator is shown before the insert. :menuselection:`Insert --> Group --> Separator` """ stereo_separation = _MixerParamProp[int](_MixerParamsID.StereoSeparation) """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 64 | 100% merged | | Max | -64 | 100% separated | | Default | 0 | No effect | """ volume = _MixerParamProp[int](_MixerParamsID.Volume) """Post volume fader. Logarithmic. | Type | Value | Representation | |---------|-------|---------------------| | Min | 0 | 0% / -INFdB / 0.00 | | Max | 16000 | 125% / 5.6dB / 1.90 | | Default | 12800 | 100% / 0.0dB / 1.00 | """ class _MixerKW(TypedDict): version: FLVersion # TODO FL Studio version in which slots were increased to 10 # TODO A move() method to change the placement of Inserts; it's difficult! class Mixer(EventModel, ModelCollection[Insert]): """Represents the mixer which contains :class:`Insert` instances. ![](https://bit.ly/3eOsblF) """ _MAX_INSERTS = { (1, 6, 5): 5, (2, 0, 1): 8, (3, 0, 0): 18, (3, 3, 0): 20, (4, 0, 0): 64, (9, 0, 0): 105, (12, 9, 0): 127, } _MAX_SLOTS = {(1, 6, 5): 4, (3, 0, 0): 8} def __init__(self, events: EventTree, **kw: Unpack[_MixerKW]) -> None: super().__init__(events, **kw) # Inserts don't store their index internally. @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Insert: """Returns an insert with the specified index or name. Args: i: An index between 0 to :attr:`Mixer.max_inserts` resembling the one shown by FL Studio or the name of the insert. Use 0 for master and -1 for "current" insert. Raises: ModelNotFound: An :class:`Insert` with the specifcied name or index isn't found. """ for idx, insert in enumerate(self): if (isinstance(i, int) and idx == i + 1) or i == insert.name: return insert raise ModelNotFound(i) def __iter__(self) -> Iterator[Insert]: def select(e: AnyEvent) -> bool | None: if e.id == InsertID.Output: return False if e.id in (*InsertID, *PluginID, *SlotID): return True params: dict[int, _InsertItems] = {} if MixerID.Params in self.events.ids: params = cast(MixerParamsEvent, self.events.first(MixerID.Params)).items_ for i, ed in enumerate(self.events.subtrees(select, self.max_inserts)): if i in params: yield Insert(ed, iid=i - 1, max_slots=self.max_slots, params=params[i]) else: yield Insert(ed, iid=i - 1, max_slots=self.max_slots) def __len__(self) -> int: """Returns the number of inserts present in the project. Raises: NoModelsFound: No inserts could be found. """ if InsertID.Flags not in self.events.ids: raise NoModelsFound return self.events.count(InsertID.Flags) def __str__(self) -> str: return f"Mixer: {len(self)} inserts" apdc = EventProp[bool](MixerID.APDC) """Whether automatic plugin delay compensation is enabled for the inserts.""" @property def max_inserts(self) -> int: """Estimated max number of inserts including sends, master and current. Maximum number of slots w.r.t. FL Studio: * 1.6.5: 4 inserts + master, 5 in total * 2.0.1: 8 * 3.0.0: 16 inserts, 2 sends. * 3.3.0: +2 sends. * 4.0.0: 64 * 9.0.0: 99 inserts, 105 in total. * 12.9.0: 125 + master + current. """ version = dataclasses.astuple(self._kw["version"]) for k, v in self._MAX_INSERTS.items(): if version <= k: return v return 127 @property def max_slots(self) -> int: """Estimated max number of effect slots per insert. Maximum number of slots w.r.t. FL Studio: * 1.6.5: 4 * 3.3.0: 8 """ version = dataclasses.astuple(self._kw["version"]) for k, v in self._MAX_SLOTS.items(): if version <= k: return v return 10 ================================================ FILE: pyflp/pattern.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by patterns, MIDI notes and their automation data.""" from __future__ import annotations import enum from collections import defaultdict from typing import DefaultDict, Iterator, cast import construct as c from pyflp._adapters import StdEnum from pyflp._descriptors import EventProp, FlagProp, StructProp from pyflp._events import ( DATA, DWORD, TEXT, WORD, BoolEvent, ColorEvent, EventEnum, EventTree, I32Event, IndexedEvent, ListEventBase, U16Event, U32Event, ) from pyflp._models import EventModel, ItemModel, ModelCollection, ModelReprMixin, supports_slice from pyflp.exceptions import ModelNotFound, NoModelsFound from pyflp.timemarker import TimeMarker, TimeMarkerID from pyflp.types import RGBA __all__ = ["Note", "Controller", "Pattern", "Patterns"] class ControllerEvent(ListEventBase): STRUCT = c.GreedyRange( c.Struct( "position" / c.Int32ul, # 4, can be delta as well! "_u1" / c.Byte, # 5 "_u2" / c.Byte, # 6 "channel" / c.Int8ul, # 7 "_flags" / c.Int8ul, # 8 "value" / c.Float32l, # 12 ) ) @enum.unique class _NoteFlags(enum.IntFlag): Slide = 1 << 3 class NotesEvent(ListEventBase): STRUCT = c.GreedyRange( c.Struct( "position" / c.Int32ul, # 4 "flags" / StdEnum[_NoteFlags](c.Int16ul), # 6 "rack_channel" / c.Int16ul, # 8 "length" / c.Int32ul, # 12 "key" / c.Int16ul, # 14 "group" / c.Int16ul, # 16 "fine_pitch" / c.Int8ul, # 17 "_u1" / c.Byte, # 18 "release" / c.Int8ul, # 19 "midi_channel" / c.Int8ul, # 20 "pan" / c.Int8ul, # 21 "velocity" / c.Int8ul, # 22 "mod_x" / c.Int8ul, # 23 "mod_y" / c.Int8ul, # 24 ) ) class PatternsID(EventEnum): PlayTruncatedNotes = (30, BoolEvent) CurrentlySelected = (WORD + 3, U16Event) # ChannelIID, _161, _162, Looped, Length occur when pattern is looped. # ChannelIID and _161 occur for every channel in order. class PatternID(EventEnum): Looped = (26, BoolEvent) New = (WORD + 1, U16Event) # Marks the beginning of a new pattern, twice. Color = (DWORD + 22, ColorEvent) Name = TEXT + 1 # _157 = DWORD + 29 #: 12.5+ # _158 = DWORD + 30 # default: -1 ChannelIID = (DWORD + 32, U32Event) # TODO (FL v20.1b1+) _161 = (DWORD + 33, I32Event) # TODO -3 if channel is looped else 0 (FL v20.1b1+) _162 = (DWORD + 34, U32Event) # TODO Appears when pattern is looped, default: 2 Length = (DWORD + 36, U32Event) Controllers = (DATA + 15, ControllerEvent) Notes = (DATA + 16, NotesEvent) class Note(ItemModel[NotesEvent]): _NOTE_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") def __repr__(self) -> str: return "Note(key={}, position={}, length={}, channel={})".format( self.key, self.position, self.length, self.rack_channel ) def __str__(self) -> str: return f"{self.key} note @ {self.position} of {self.length}" fine_pitch = StructProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -1200 cents | | Max | 240 | +1200 cents | | Default | 120 | No fine tuning | *New in FL Studio v3.3.0*. """ group = StructProp[int]() """A number shared by notes in the same group or ``0`` if ungrouped. ![](https://bit.ly/3TgjFva) """ @property def key(self) -> str: """Note name with octave, for e.g. 'C5' or 'A#3' ranging from C0 to B10. Only sharp key names (C#, D#, etc.) are used, flats aren't. Raises: ValueError: A value not in between 0-131 is tried to be set. ValueError: Invalid note name (not in the format {note-name}{octave}). """ return self._NOTE_NAMES[self["key"] % 12] + str(self["key"] // 12) # pyright: ignore @key.setter def key(self, value: int | str) -> None: if isinstance(value, int): if value not in range(132): raise ValueError("Expected a value between 0-131.") self["key"] = value else: for i, name in enumerate(self._NOTE_NAMES): if value.startswith(name): octave = int(value.replace(name, "", 1)) self["key"] = octave * 12 + i raise ValueError(f"Invalid key name: {value}") length = StructProp[int]() """Returns 0 for notes punched in through step sequencer.""" midi_channel = StructProp[int]() """Used for a variety of purposes. For note colors, min: 0, max: 15. +128 for MIDI dragged into the piano roll. *Changed in FL Studio v6.0.1*: Used for both, MIDI channels and colors. """ mod_x = StructProp[int]() """Plugin configurable parameter. | Min | Max | Default | | --- | --- | ------- | | 0 | 255 | 128 | """ mod_y = StructProp[int]() """Plugin configurable parameter. | Min | Max | Default | | --- | --- | ------- | | 0 | 255 | 128 | """ pan = StructProp[int]() """ | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 100% left | | Max | 128 | 100% right | | Default | 64 | Centered | """ position = StructProp[int]() rack_channel = StructProp[int]() """Containing channel's :attr:`Channel.IID`.""" release = StructProp[int]() """ | Min | Max | Default | | --- | --- | ------- | | 0 | 128 | 64 | """ slide = FlagProp(_NoteFlags.Slide) """Whether note is a sliding note.""" velocity = StructProp[int]() """ | Min | Max | Default | | --- | --- | ------- | | 0 | 128 | 100 | """ class Controller(ItemModel[ControllerEvent], ModelReprMixin): def __str__(self) -> str: return f"Controller @ {self.position} of channel #{self.channel}" channel = StructProp[int]() """Corresponds to the containing channel's :attr:`Channel.iid`.""" position = StructProp[int]() value = StructProp[float]() class Pattern(EventModel): """Represents a pattern which can contain notes, controllers and time markers.""" def __repr__(self) -> str: try: num_notes = len(self.events.first(PatternID.Notes)) # type: ignore except KeyError: num_notes = 0 try: num_ctrls = len(self.events.first(PatternID.Controllers)) # type: ignore except KeyError: num_ctrls = 0 return ( f"Pattern(iid={self.iid}, name={self.name!r}," f"{num_notes} notes, {num_ctrls} controllers)" ) color = EventProp[RGBA](PatternID.Color) """Returns a colour if one is set while saving the project file, else ``None``. ![](https://bit.ly/3eNeSSW) Defaults to #485156 in FL Studio. """ @property def controllers(self) -> Iterator[Controller]: """Parameter automations associated with this pattern (if any).""" if PatternID.Controllers in self.events.ids: event = cast(ControllerEvent, self.events.first(PatternID.Controllers)) yield from (Controller(item, i, event) for i, item in enumerate(event)) @property def iid(self) -> int: """Internal index of the pattern starting from 1. Caution: Changing this will not solve any collisions thay may occur due to 2 patterns that might end up having the same index. """ return self.events.first(PatternID.New).value @iid.setter def iid(self, value: int) -> None: for event in self.events.get(PatternID.New): event.value = value length = EventProp[int](PatternID.Length) """The number of steps multiplied by the :attr:`pyflp.project.Project.ppq`. Returns `None` if pattern is in Auto mode (i.e. :attr:`looped` is `False`). """ looped = EventProp[bool](PatternID.Looped, default=False) """Whether a pattern is in live loop mode. *New in FL Studio v2.5.0*. """ name = EventProp[str](PatternID.Name) """User given name of the pattern; None if not set.""" @property def notes(self) -> Iterator[Note]: """MIDI notes contained inside the pattern. Note: FL Studio uses its own custom format to represent notes internally. However by using the :class:`Note` properties with a MIDI parsing library for example, you can export them to MIDI. """ if PatternID.Notes in self.events.ids: event = cast(NotesEvent, self.events.first(PatternID.Notes)) yield from (Note(item, i, event) for i, item in enumerate(event)) @property def timemarkers(self) -> Iterator[TimeMarker]: """Yields timemarkers inside this pattern.""" yield from (TimeMarker(et) for et in self.events.group(*TimeMarkerID)) class Patterns(EventModel, ModelCollection[Pattern]): def __str__(self) -> str: iids = [pattern.iid for pattern in self] return f"{len(iids)} Patterns {iids!r}" @supports_slice # type: ignore def __getitem__(self, i: int | str | slice) -> Pattern: """Returns the pattern with the specified index or :attr:`Pattern.name`. Args: i: A zero-based index, its name or a slice of indexes. Raises: ModelNotFound: A :class:`Pattern` with the specified name or index isn't found. """ for idx, pattern in enumerate(self): if (isinstance(i, int) and idx == i) or i == pattern.name: return pattern raise ModelNotFound(i) # Doesn't use EventTree delegates since PatternID.New occurs twice. # Once for note and controller events and again for the rest of them. def __iter__(self) -> Iterator[Pattern]: """An iterator over the patterns found in the project.""" cur_pat_id = 0 tmp_dict: DefaultDict[int, list[IndexedEvent]] = defaultdict(list) for ie in self.events.lst: if ie.e.id == PatternID.New: cur_pat_id = ie.e.value if ie.e.id in (*PatternID, *TimeMarkerID): tmp_dict[cur_pat_id].append(ie) for events in tmp_dict.values(): et = EventTree(self.events, events) self.events.children.append(et) yield Pattern(et) def __len__(self) -> int: """Returns the number of patterns found in the project. Raises: NoModelsFound: No patterns were found. """ if PatternID.New not in self.events.ids: raise NoModelsFound return len({e.value for e in self.events.get(PatternID.New)}) play_cut_notes = EventProp[bool](PatternsID.PlayTruncatedNotes) """Whether truncated notes of patterns placed in the playlist should be played. Located at :menuselection:`Options -> &Project general settings --> Advanced` under the name :guilabel:`Play truncated notes in clips`. *Changed in FL Studio v12.3 beta 3*: Enabled by default. """ @property def current(self) -> Pattern | None: """Returns the currently selected pattern.""" if PatternsID.CurrentlySelected in self.events.ids: index = self.events.first(PatternsID.CurrentlySelected).value for pattern in self: if pattern.iid == index: return pattern ================================================ FILE: pyflp/plugin.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types used by native and VST plugins to store their preset data.""" from __future__ import annotations import enum import warnings from typing import Any, ClassVar, Dict, Generic, Literal, Protocol, TypeVar, cast, runtime_checkable import construct as c import construct_typed as ct from pyflp._adapters import FourByteBool, StdEnum from pyflp._descriptors import FlagProp, NamedPropMixin, RWProperty, StructProp from pyflp._events import ( DATA, DWORD, TEXT, AnyEvent, ColorEvent, EventEnum, EventTree, StructEventBase, U32Event, UnknownDataEvent, ) from pyflp._models import EventModel, ModelReprMixin from pyflp.types import T __all__ = [ "BooBass", "FruitKick", "FruityBalance", "FruityBloodOverdrive", "FruityFastDist", "FruityNotebook2", "FruitySend", "FruitySoftClipper", "FruityStereoEnhancer", "Plucked", "PluginID", "PluginIOInfo", "Soundgoodizer", "VSTPlugin", ] @enum.unique class _WrapperFlags(enum.IntFlag): Visible = 1 << 0 _Disabled = 1 << 1 Detached = 1 << 2 # _U3 = 1 << 3 Generator = 1 << 4 SmartDisable = 1 << 5 ThreadedProcessing = 1 << 6 DemoMode = 1 << 7 # saved with a demo version HideSettings = 1 << 8 Minimized = 1 << 9 _DirectX = 1 << 16 # indicates the plugin is a DirectX plugin _EditorSize = 2 << 16 class BooBassEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.If(c.this._.len == 16, c.Bytes(4)), "bass" / c.Int32ul, "mid" / c.Int32ul, "high" / c.Int32ul, ).compile() class FruitKickEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.Bytes(4), "max_freq" / c.Int32sl, "min_freq" / c.Int32sl, "freq_decay" / c.Int32ul, "amp_decay" / c.Int32ul, "click" / c.Int32ul, "distortion" / c.Int32ul, "_u2" / c.Bytes(4), ).compile() class FruityBalanceEvent(StructEventBase): STRUCT = c.Struct("pan" / c.Int32ul, "volume" / c.Int32ul).compile() class FruityBloodOverdriveEvent(StructEventBase): STRUCT = c.Struct( "plugin_marker" / c.If(c.this._.len == 36, c.Bytes(4)), # redesigned native plugin marker "pre_band" / c.Int32ul, "color" / c.Int32ul, "pre_amp" / c.Int32ul, "x100" / FourByteBool, "post_filter" / c.Int32ul, "post_gain" / c.Int32ul, "_u1" / c.Bytes(4), "_u2" / c.Bytes(4), ).compile() class FruityCenterEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.If(c.this._.len == 8, c.Bytes(4)), "enabled" / FourByteBool ).compile() class FruityFastDistEvent(StructEventBase): STRUCT = c.Struct( "pre" / c.Int32ul, "threshold" / c.Int32ul, "kind" / c.Enum(c.Int32ul, A=0, B=1), "mix" / c.Int32ul, "post" / c.Int32ul, ).compile() class FruityNotebook2Event(StructEventBase): STRUCT = c.Struct( "_u1" / c.Bytes(4), "active_page" / c.Int32ul, "pages" / c.GreedyRange( c.Struct( "index" / c.Int32sl, c.StopIf(lambda ctx: ctx["index"] == -1), "length" / c.VarInt, "value" / c.PaddedString(lambda ctx: ctx["length"] * 2, "utf-16-le"), ), ), "editable" / c.Flag, ) class FruitySendEvent(StructEventBase): STRUCT = c.Struct( "pan" / c.Int32sl, "dry" / c.Int32ul, "volume" / c.Int32ul, "send_to" / c.Int32sl, ).compile() class FruitySoftClipperEvent(StructEventBase): STRUCT = c.Struct("threshold" / c.Int32ul, "post" / c.Int32ul).compile() class FruityStereoEnhancerEvent(StructEventBase): STRUCT = c.Struct( "pan" / c.Int32sl, "volume" / c.Int32ul, "stereo_separation" / c.Int32ul, "phase_offset" / c.Int32ul, "effect_position" / c.Enum(c.Int32ul, pre=0, post=1), "phase_inversion" / c.Enum(c.Int32ul, none=0, left=1, right=2), ).compile() class PluckedEvent(StructEventBase): STRUCT = c.Struct( "decay" / c.Int32ul, "color" / c.Int32ul, "normalize" / FourByteBool, "gate" / FourByteBool, "widen" / FourByteBool, ).compile() class SoundgoodizerEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.If(c.this._.len == 12, c.Bytes(4)), "mode" / c.Enum(c.Int32ul, A=0, B=1, C=2, D=3), "amount" / c.Int32ul, ).compile() NativePluginEvent = UnknownDataEvent """Placeholder event type for unimplemented native :attr:`PluginID.Data` events.""" class WrapperPage(ct.EnumBase): Editor = 0 """:guilabel:`Plugin editor`.""" Settings = 1 """:guilabel:`VST wrapper settings`.""" Sample = 3 """:guilabel:`Sample settings`.""" Envelope = 4 """:guilabel:`Envelope / instrument settings`.""" Miscellaneous = 5 """:guilabel:`Miscallenous functions`.""" class WrapperEvent(StructEventBase): STRUCT = c.Struct( "_u1" / c.Optional(c.Bytes(16)), # 16 "flags" / c.Optional(StdEnum[_WrapperFlags](c.Int16ul)), # 18 "_u2" / c.Optional(c.Bytes(2)), # 20 "page" / c.Optional(StdEnum[WrapperPage](c.Int8ul)), # 21 "_u3" / c.Optional(c.Bytes(23)), # 44 "width" / c.Optional(c.Int32ul), # 48 "height" / c.Optional(c.Int32ul), # 52 "_extra" / c.GreedyBytes, # None as of 20.9.2 ).compile() @enum.unique class _VSTPluginEventID(ct.EnumBase): MIDI = 1 Flags = 2 IO = 30 Inputs = 31 Outputs = 32 PluginInfo = 50 FourCC = 51 # Not present for Waveshells & VST3 GUID = 52 State = 53 Name = 54 PluginPath = 55 Vendor = 56 _57 = 57 # TODO, not present for Waveshells class _VSTFlags(enum.IntFlag): SendPBRange = 1 << 0 FixedSizeBuffers = 1 << 1 NotifyRender = 1 << 2 ProcessInactive = 1 << 3 DontSendRelVelo = 1 << 5 DontNotifyChanges = 1 << 6 SendLoopPos = 1 << 11 AllowThreaded = 1 << 12 KeepFocus = 1 << 15 DontKeepCPUState = 1 << 16 SendModX = 1 << 17 LoadBridged = 1 << 18 ExternalWindow = 1 << 21 UpdateWhenHidden = 1 << 23 DontResetOnTransport = 1 << 25 DPIAwareBridged = 1 << 26 AcceptFileDrop = 1 << 28 AllowSmartDisable = 1 << 29 ScaleEditor = 1 << 30 DontUseTimeOffset = 1 << 31 class _VSTFlags2(enum.IntFlag): ProcessMaxSize = 1 << 0 UseMaxFromHost = 1 << 1 class VSTPluginEvent(StructEventBase): _MIDIStruct = c.Struct( "input" / c.Optional(c.Int32sl), # 4 "output" / c.Optional(c.Int32sl), # 8 "pb_range" / c.Optional(c.Int32ul), # 12 "_extra" / c.GreedyBytes, # upto 20 ).compile() _FlagsStruct = c.Struct( "_u1" / c.Optional(c.Bytes(9)), # 9 "flags" / c.Optional(StdEnum[_VSTFlags](c.Int32ul)), # 13 "flags2" / c.Optional(StdEnum[_VSTFlags2](c.Int32ul)), # 17 "_u2" / c.Optional(c.Bytes(5)), # 22 "fast_idle" / c.Optional(c.Flag), # 23 "_extra" / c.GreedyBytes, ).compile() STRUCT = c.Struct( "type" / c.Int32ul, # * 8 or 10 for VSTs, but I am not forcing it "events" / c.GreedyRange( c.Struct( "id" / StdEnum[_VSTPluginEventID](c.Int32ul), # ! Using a c.Select or c.IfThenElse doesn't work here # Check https://github.com/construct/construct/issues/993 "data" # pyright: ignore / c.Prefixed( c.Int64ul, c.Switch( c.this["id"], { _VSTPluginEventID.MIDI: _MIDIStruct, _VSTPluginEventID.Flags: _FlagsStruct, _VSTPluginEventID.FourCC: c.GreedyString("utf8"), _VSTPluginEventID.Name: c.GreedyString("utf8"), # See #150 _VSTPluginEventID.Vendor: c.GreedyString("utf8"), _VSTPluginEventID.PluginPath: c.GreedyString("utf8"), }, default=c.GreedyBytes, ), ), ), ), ).compile() def __init__(self, id: Any, data: bytearray) -> None: if data[0] not in (8, 10): warnings.warn( f"VSTPluginEvent: Unknown marker {data[0]} detected. " "Open an issue at https://github.com/demberto/PyFLP/issues " "if you are seeing this!", RuntimeWarning, stacklevel=3, ) super().__init__(id, data) @enum.unique class PluginID(EventEnum): """IDs shared by :class:`pyflp.channel.Channel` and :class:`pyflp.mixer.Slot`.""" Color = (DWORD, ColorEvent) Icon = (DWORD + 27, U32Event) InternalName = TEXT + 9 Name = TEXT + 11 #: 3.3.0+ for :class:`pyflp.mixer.Slot`. # TODO Additional possible fields: Plugin wrapper data, window # positions of plugin, currently selected plugin wrapper page, etc. Wrapper = (DATA + 4, WrapperEvent) # * The type of this event is decided during event collection Data = DATA + 5 #: 1.6.5+ @runtime_checkable class _IPlugin(Protocol): INTERNAL_NAME: ClassVar[str] """The name used internally by FL to decide the type of plugin data.""" _PE_co = TypeVar("_PE_co", bound=AnyEvent, covariant=True) class _WrapperProp(FlagProp): def __init__(self, flag: _WrapperFlags, **kw: Any) -> None: super().__init__(flag, PluginID.Wrapper, **kw) class _PluginBase(EventModel, Generic[_PE_co]): def __init__(self, events: EventTree, **kw: Any) -> None: super().__init__(events, **kw) compact = _WrapperProp(_WrapperFlags.HideSettings) """Whether plugin page toolbar (:guilabel:`Detailed settings`) is hidden. ![](https://bit.ly/3qzOMoO) """ demo_mode = _WrapperProp(_WrapperFlags.DemoMode) # TODO Verify if this works """Whether the plugin state was saved in a demo / trial version.""" detached = _WrapperProp(_WrapperFlags.Detached) """Plugin editor can be moved between different monitors when detached.""" disabled = _WrapperProp(_WrapperFlags._Disabled) """This is a legacy property; DON'T use it. Check :attr:`Channel.enabled` or :attr:`Slot.enabled` instead. """ directx = _WrapperProp(_WrapperFlags._DirectX) """Whether the plugin is a DirectX plugin or not.""" generator = _WrapperProp(_WrapperFlags.Generator) """Whether the plugin is a generator or an effect.""" height = StructProp[int](PluginID.Wrapper) """Height of the plugin editor (in pixels).""" minimized = _WrapperProp(_WrapperFlags.Minimized) """Whether the plugin editor is maximized or minimized. ![](https://bit.ly/3QDMWO3) """ multithreaded = _WrapperProp(_WrapperFlags.ThreadedProcessing) """Whether threaded processing is enabled or not.""" page = StructProp[WrapperPage](PluginID.Wrapper) """Active / selected / current page. ![](https://bit.ly/3ffJKM3) """ smart_disable = _WrapperProp(_WrapperFlags.SmartDisable) """Whether smart disable is enabled or not.""" visible = _WrapperProp(_WrapperFlags.Visible) """Whether the editor of the plugin is visible or closed.""" width = StructProp[int](PluginID.Wrapper) """Width of the plugin editor (in pixels).""" AnyPlugin = _PluginBase[AnyEvent] # TODO alias to _IPlugin + _PluginBase (both) class PluginProp(RWProperty[AnyPlugin]): def __init__(self, *types: type[AnyPlugin]) -> None: self._types = types @staticmethod def _get_plugin_events(ins: EventModel) -> EventTree: return ins.events.subtree(lambda e: e.id in (PluginID.Wrapper, PluginID.Data)) def __get__(self, ins: EventModel, owner: Any = None) -> AnyPlugin | None: if owner is None: return NotImplemented try: data_event = ins.events.first(PluginID.Data) except KeyError: return None if isinstance(data_event, UnknownDataEvent): return _PluginBase(self._get_plugin_events(ins)) for ptype in self._types: event_type = ptype.__orig_bases__[0].__args__[0] # type: ignore if isinstance(data_event, event_type): return ptype(self._get_plugin_events(ins)) def __set__(self, ins: EventModel, value: AnyPlugin) -> None: if isinstance(value, _IPlugin): setattr(ins, "internal_name", value.INTERNAL_NAME) for id in (PluginID.Data, PluginID.Wrapper): for ie in ins.events.lst: if ie.e.id == id: ie.e = value.events.first(id) class _NativePluginProp(StructProp[T]): def __init__(self, prop: str | None = None, **kwds: Any) -> None: super().__init__(PluginID.Data, prop=prop, **kwds) class _VSTPluginProp(RWProperty[T], NamedPropMixin): def __init__(self, id: _VSTPluginEventID, prop: str | None = None) -> None: self._id = id NamedPropMixin.__init__(self, prop) def __get__(self, ins: EventModel, _=None) -> T: event = cast(VSTPluginEvent, ins.events.first(PluginID.Data)) for e in event["events"]: if e["id"] == self._id: return self._get(e["data"]) raise AttributeError(self._id) def _get(self, value: Any) -> T: return cast(T, value if isinstance(value, (str, bytes)) else value[self._prop]) def __set__(self, ins: EventModel, value: T) -> None: self._set(cast(VSTPluginEvent, ins.events.first(PluginID.Data)), value) def _set(self, event: VSTPluginEvent, value: T) -> None: for e in event["events"]: if e["id"] == self._id: e["data"] = value break class _VSTFlagProp(_VSTPluginProp[bool]): def __init__( self, flag: _VSTFlags | _VSTFlags2, prop: str = "flags", inverted: bool = False ) -> None: super().__init__(_VSTPluginEventID.Flags, prop) self._flag = flag self._inverted = inverted def _get(self, value: Any) -> bool: retbool = self._flag in value[self._prop] return retbool if not self._inverted else not retbool def _set(self, event: VSTPluginEvent, value: bool) -> None: if self._inverted: value = not value for e in event["events"]: if e["id"] == self._id: if value: e["data"][self._prop] |= value else: e["data"][self._prop] &= ~value break class PluginIOInfo(EventModel): mixer_offset = StructProp[int]() flags = StructProp[int]() class VSTPlugin(_PluginBase[VSTPluginEvent], _IPlugin): """Represents a VST2 or a VST3 generator or effect. *New in FL Studio v1.5.23*: VST2 support (beta). *New in FL Studio v9.0.3*: VST3 support. """ INTERNAL_NAME = "Fruity Wrapper" def __repr__(self) -> str: return f"VSTPlugin(name={self.name!r}, vendor={self.vendor!r})" class _AutomationOptions(EventModel): """See :attr:`VSTPlugin.automation`.""" notify_changes = _VSTFlagProp(_VSTFlags.DontNotifyChanges, inverted=True) """Record parameter changes as automation. :guilabel:`Notify about parameter changes`. Defaults to ``True``. """ class _CompatibilityOptions(EventModel): """See :attr:`VSTPlugin.compatibility`.""" buffers_maxsize = _VSTFlagProp(_VSTFlags2.UseMaxFromHost, prop="flags2") """:guilabel:`Use maximum buffer size from host`. Defaults to ``False``.""" fast_idle = _VSTPluginProp[bool](_VSTPluginEventID.Flags) """Increases idle rate - can make plugin GUI feel more responsive if its slow. May increase CPU usage. Defaults to ``False``. """ fixed_buffers = _VSTFlagProp(_VSTFlags.FixedSizeBuffers) """:guilabel:`Use fixed size buffers`. Defaults to ``False``. Makes FL Studio send fixed size buffers instead of variable ones when ``True``. Can fix rendering errors caused by plugins. Increases latency by 2ms. """ process_maximum = _VSTFlagProp(_VSTFlags2.ProcessMaxSize, prop="flags2") """:guilabel:`Process maximum size buffers`. Defaults to ``False``.""" reset_on_transport = _VSTFlagProp(_VSTFlags.DontResetOnTransport, inverted=True) """:guilabel:`Reset plugin when FL Studio resets`. Defaults to ``True``.""" send_loop = _VSTFlagProp(_VSTFlags.SendLoopPos) """Lets the plugin know about :attr:`Arrangemnt.loop_pos`. :guilabel:`Send loop position`. Defaults to ``True``. """ use_time_offset = _VSTFlagProp(_VSTFlags.DontUseTimeOffset, inverted=True) """Adjust time information reported by plugin. Can fix timing issues caused by plugins in FL Studio <20.7 project. :guilabel:`Use time offset`. Defaults to ``False``. """ class _MIDIOptions(EventModel): """See :attr:`VSTPlugin.midi`. ![](https://bit.ly/3NbGr4U) """ input = _VSTPluginProp[int](_VSTPluginEventID.MIDI) """MIDI Input Port. Min = 0, Max = 255. Not selected = -1 (default).""" output = _VSTPluginProp[int](_VSTPluginEventID.MIDI) """MIDI Output Port. Min = 0, Max = 255. Not selected = -1 (default).""" pb_range = _VSTPluginProp[int](_VSTPluginEventID.MIDI) """Pitch bend range MIDI RPN sent to the plugin (in semitones). Min = 1. Max = 48. Defaults to 12. """ send_modx = _VSTFlagProp(_VSTFlags.SendModX) """:guilabel:`Send MOD X as polyphonic aftertouch`. Defaults to ``False``.""" send_pb = _VSTFlagProp(_VSTFlags.SendPBRange) """:guilabel:`Send pitch bend range (semitones)`. Defaults to ``False``. See also: :attr:`pb_range` - Sent to plugin as a MIDI RPN if this is ``True``. """ send_release = _VSTFlagProp(_VSTFlags.DontSendRelVelo, inverted=True) """Whether release velocity should be sent in note off messages. :guilabel:`Send note release velocity`. Defaults to ``True``. """ class _ProcessingOptions(EventModel): """See :attr:`VSTPlugin.processing`.""" allow_sd = _VSTFlagProp(_VSTFlags.AllowSmartDisable) """:guilabel:`Allow smart disable`. Defaults to ``True``. Disables the :attr:`VSTPlugin.smart_disable` feature if ``False``. """ bridged = _VSTFlagProp(_VSTFlags.LoadBridged) """Load a plugin in separate process. :guilabel:`Make bridged`. Defaults to ``False``. """ external = _VSTFlagProp(_VSTFlags.ExternalWindow) """Keep plugin editor in bridge process. :guilabel:`External window`. Defaults to ``False``. """ keep_state = _VSTFlagProp(_VSTFlags.DontKeepCPUState, inverted=True) """Don't touch unless you have issues like DC offsets, spikes and crashes. :guilabel:`Ensure processor state in callbacks`. Defaults to ``True``. """ multithreaded = _VSTFlagProp(_VSTFlags.AllowThreaded) """Allow plugin to be multi-threaded by FL Studio. Disables the :attr:`VSTPlugin.multithreaded` feature if ``False``. :guilabel:`Allow threaded processing`. Defaults to ``True``. """ notify_render = _VSTFlagProp(_VSTFlags.NotifyRender) """Lets the plugin know when rendering to audio file. This can be used by the plugin to switch to HQ processing or disable output entirely if it is in demo mode (depends on the plugin logic). :guilabel:`Notify about rendering mode`. Defaults to ``True``. """ process_inactive = _VSTFlagProp(_VSTFlags.ProcessInactive) """Make FL Studio also process inputs / outputs marked as inactive by plugin. :guilabel:`Process inactive inputs and outputs`. Defaults to ``True``. """ class _UIOptions(EventModel): """See :attr:`VSTPlugin.ui`. ![](https://bit.ly/3Nb3dtP) """ accept_drop = _VSTFlagProp(_VSTFlags.AcceptFileDrop) """Host is bypassed when a file is dropped on the plugin editor. :guilabel:`Accept dropped files`. Defaults to ``False``. """ always_update = _VSTFlagProp(_VSTFlags.UpdateWhenHidden) """Whether plugin UI should be updated when hidden; default to ``False``.""" dpi_aware = _VSTFlagProp(_VSTFlags.DPIAwareBridged) """Enable if plugin editors look too big or small. :guilabel:`DPI aware when bridged`. Defaults to ``True``. """ scale_editor = _VSTFlagProp(_VSTFlags.ScaleEditor) """Scale dimensions of editor that appear cut-off on high-res screens. :guilabel:`Scale editor dimensions`. Defaults to ``False``. """ def __init__(self, events: EventTree, **kw: Any) -> None: super().__init__(events, **kw) # This doesn't break lazy evaluation in any way self.automation = self._AutomationOptions(events) self.compatibility = self._CompatibilityOptions(events) self.midi = self._MIDIOptions(events) self.processing = self._ProcessingOptions(events) self.ui = self._UIOptions(events) fourcc = _VSTPluginProp[str](_VSTPluginEventID.FourCC) """A unique four character code identifying the plugin. A database can be found on Steinberg's developer portal. """ guid = _VSTPluginProp[bytes](_VSTPluginEventID.GUID) # See issue #8 name = _VSTPluginProp[str](_VSTPluginEventID.Name) """Factory name of the plugin.""" # num_inputs = _VSTPluginProp[int]() # """Number of inputs the plugin supports.""" # num_outputs = _VSTPluginProp[int]() # """Number of outputs the plugin supports.""" plugin_path = _VSTPluginProp[str](_VSTPluginEventID.PluginPath) """The absolute path to the plugin binary.""" state = _VSTPluginProp[bytes](_VSTPluginEventID.State) """Plugin specific preset data blob.""" vendor = _VSTPluginProp[str](_VSTPluginEventID.Vendor) """Plugin developer (vendor) name.""" # vst_number = _VSTPluginProp[int]() # TODO class BooBass(_PluginBase[BooBassEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3Bk3aGK)""" INTERNAL_NAME = "BooBass" bass = _NativePluginProp[int]() """Volume of the bass region. | Min | Max | Default | |-----|-------|---------| | 0 | 65535 | 32767 | """ high = _NativePluginProp[int]() """Volume of the high region. | Min | Max | Default | |-----|-------|---------| | 0 | 65535 | 32767 | """ mid = _NativePluginProp[int]() """Volume of the mid region. | Min | Max | Default | |-----|-------|---------| | 0 | 65535 | 32767 | """ class FruitKick(_PluginBase[FruitKickEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/41fIPxE)""" INTERNAL_NAME = "Fruit Kick" amp_decay = _NativePluginProp[int]() """Amplitude (volume) decay length. Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0% | | Max | 256 | 100% | | Default | 128 | 50% | """ click = _NativePluginProp[int]() """Amount of phase offset added to produce a click. Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0% | | Max | 64 | 100% | | Default | 32 | 50% | """ distortion = _NativePluginProp[int]() """Linear. Defaults to minimum. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0% | | Max | 128 | 100% | """ freq_decay = _NativePluginProp[int]() """Pitch sweep time / pitch decay. Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0% | | Max | 256 | 100% | | Default | 64 | 25% | """ max_freq = _NativePluginProp[int]() """Start frequency. Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -900 | -67% | | Max | 3600 | 100% | | Default | 0 | 0% | """ min_freq = _NativePluginProp[int]() """Sweep to / end frequency. Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -1200 | -100% | | Max | 1200 | 100% | | Default | -600 | -50% | """ class FruityBalance(_PluginBase[FruityBalanceEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3RWItqU)""" INTERNAL_NAME = "Fruity Balance" pan = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | 100% left | | Max | 127 | 100% right | | Default | 0 | Centred | """ volume = _NativePluginProp[int]() """Logarithmic. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -INFdB / 0.00 | | Max | 320 | 5.6dB / 1.90 | | Default | 256 | 0.0dB / 1.00 | """ class FruityBloodOverdrive(_PluginBase[FruityBloodOverdriveEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3LnS1LE)""" INTERNAL_NAME = "Fruity Blood Overdrive" pre_band = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0.0000 | | Max | 10000 | 1.0000 | | Default | 0 | 0.0000 | """ color = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0.0000 | | Max | 10000 | 1.0000 | | Default | 5000 | 0.5000 | """ pre_amp = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0.0000 | | Max | 10000 | 1.0000 | | Default | 0 | 0.0000 | """ x100 = _NativePluginProp[bool]() """Boolean. | Type | Value | Representation | |---------|-------|----------------| | Off | 0 | Off | | On | 1 | On | | Default | 0 | Off | """ post_filter = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | 0.0000 | | Max | 10000 | 1.0000 | | Default | 0 | 0.0000 | """ post_gain = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -1.0000 | | Max | 10000 | 0.0000 | | Default | 10000 | 0.0000 | """ class FruityCenter(_PluginBase[FruityCenterEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3TA9IIv)""" INTERNAL_NAME = "Fruity Center" enabled = _NativePluginProp[bool]() """Removes DC offset if True; effectively behaving like a bypass button. Labelled as **Status** for some reason in the UI. """ class FruityFastDist(_PluginBase[FruityFastDistEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3qT6Jil)""" INTERNAL_NAME = "Fruity Fast Dist" kind = _NativePluginProp[Literal["A", "B"]]() mix = _NativePluginProp[int]() """Linear. Defaults to maximum value. | Type | Value | Mix (wet) | |------|-------|-----------| | Min | 0 | 0% | | Max | 128 | 100% | """ post = _NativePluginProp[int]() """Linear. Defaults to maximum value. | Type | Value | Mix (wet) | |------|-------|-----------| | Min | 0 | 0% | | Max | 128 | 100% | """ pre = _NativePluginProp[int]() """Linear. | Type | Value | Percentage | |---------|-------|------------| | Min | 64 | 33% | | Max | 192 | 100% | | Default | 128 | 67% | """ threshold = _NativePluginProp[int]() """Linear, Stepped. Defaults to maximum value. | Type | Value | Percentage | |------|-------|------------| | Min | 1 | 10% | | Max | 10 | 100% | """ class FruityNotebook2(_PluginBase[FruityNotebook2Event], _IPlugin, ModelReprMixin): """![](https://bit.ly/3RHa4g5)""" INTERNAL_NAME = "Fruity NoteBook 2" active_page = _NativePluginProp[int]() """Active page number of the notebook. Min: 0, Max: 100.""" editable = _NativePluginProp[bool]() """Whether the notebook is marked as editable or read-only. This attribute is just a visual marker used by FL Studio. """ pages = _NativePluginProp[Dict[int, str]]() """A dict of page numbers to their contents.""" class FruitySend(_PluginBase[FruitySendEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3DqjvMu)""" INTERNAL_NAME = "Fruity Send" dry = _NativePluginProp[int]() """Linear. Defaults to maximum value. | Type | Value | Mix (wet) | |------|-------|-----------| | Min | 0 | 0% | | Max | 256 | 100% | """ pan = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | 100% left | | Max | 127 | 100% right | | Default | 0 | Centred | """ send_to = _NativePluginProp[int]() """Target insert index; depends on insert routing. Defaults to -1 (Master).""" volume = _NativePluginProp[int]() """Logarithmic. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -INFdB / 0.00 | | Max | 320 | 5.6dB / 1.90 | | Default | 256 | 0.0dB / 1.00 | """ class FruitySoftClipper(_PluginBase[FruitySoftClipperEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3BCWfJX)""" INTERNAL_NAME = "Fruity Soft Clipper" post = _NativePluginProp[int]() """Linear. | Type | Value | Mix (wet) | |---------|-------|-----------| | Min | 0 | 0% | | Max | 160 | 100% | | Default | 128 | 80% | """ threshold = _NativePluginProp[int]() """Logarithmic. | Type | Value | Representation | |---------|-------|----------------| | Min | 1 | -INFdB / 0.00 | | Max | 127 | 0.0dB / 1.00 | | Default | 100 | -4.4dB / 0.60 | """ class FruityStereoEnhancer(_PluginBase[FruityStereoEnhancerEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3DoHvji)""" INTERNAL_NAME = "Fruity Stereo Enhancer" effect_position = _NativePluginProp[Literal["pre", "post"]]() """Defaults to ``post``.""" pan = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -128 | 100% left | | Max | 127 | 100% right | | Default | 0 | Centred | """ phase_inversion = _NativePluginProp[Literal["none", "left", "right"]]() """Default to ``None``.""" phase_offset = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -512 | 500ms L | | Max | 512 | 500ms R | | Default | 0 | No offset | """ stereo_separation = _NativePluginProp[int]() """Linear. | Type | Value | Representation | |---------|-------|----------------| | Min | -96 | 100% separated | | Max | 96 | 100% merged | | Default | 0 | No effect | """ volume = _NativePluginProp[int]() """Logarithmic. | Type | Value | Representation | |---------|-------|----------------| | Min | 0 | -INFdB / 0.00 | | Max | 320 | 5.6dB / 1.90 | | Default | 256 | 0.0dB / 1.00 | """ class Plucked(_PluginBase[PluckedEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3GuFz9k)""" INTERNAL_NAME = "Plucked!" color = _NativePluginProp[int]() """Linear. | Min | Max | Default | |-----|------|---------| | 0 | 128 | 64 | """ decay = _NativePluginProp[int]() """Linear. | Min | Max | Default | |-----|------|---------| | 0 | 256 | 128 | """ gate = _NativePluginProp[bool]() """Stops the voices abruptly when released, otherwise the decay keeps going.""" normalize = _NativePluginProp[bool]() """Same :attr:`decay` is tried to be used for all semitones. If not, higher notes have a shorter decay. """ widen = _NativePluginProp[bool]() """Enriches the stereo panorama of the sound.""" class Soundgoodizer(_PluginBase[SoundgoodizerEvent], _IPlugin, ModelReprMixin): """![](https://bit.ly/3dip70y)""" INTERNAL_NAME = "Soundgoodizer" amount = _NativePluginProp[int]() """Logarithmic. | Min | Max | Default | |-----|------|---------| | 0 | 1000 | 600 | """ mode = _NativePluginProp[Literal["A", "B", "C", "D"]]() """4 preset modes (A, B, C and D). Defaults to ``A``.""" def get_event_by_internal_name(name: str) -> type[AnyEvent]: for cls in _PluginBase.__subclasses__(): if getattr(cls, "INTERNAL_NAME", None) == name: return cls.__orig_bases__[0].__args__[0] # type: ignore return NativePluginEvent ================================================ FILE: pyflp/project.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the class (and types it uses) used by the parser and serializer.""" from __future__ import annotations import datetime import enum import math import pathlib from typing import Final, Literal, cast import construct as c import construct_typed as ct from typing_extensions import TypedDict, Unpack from pyflp._descriptors import EventProp, KWProp from pyflp._events import ( DATA, DWORD, TEXT, WORD, AnyEvent, AsciiEvent, BoolEvent, EventEnum, EventTree, I16Event, I32Event, StructEventBase, U8Event, U32Event, ) from pyflp._models import EventModel from pyflp.arrangement import ArrangementID, Arrangements, ArrangementsID, TrackID from pyflp.channel import ChannelID, ChannelRack, DisplayGroupID, RackID from pyflp.exceptions import PropertyCannotBeSet from pyflp.mixer import InsertID, Mixer, MixerID, SlotID from pyflp.pattern import PatternID, Patterns, PatternsID from pyflp.plugin import PluginID from pyflp.timemarker import TimeMarkerID from pyflp.types import FLVersion __all__ = ["PanLaw", "Project", "FileFormat", "VALID_PPQS"] _DELPHI_EPOCH: Final = datetime.datetime(1899, 12, 30) MIN_TEMPO: Final = 10.000 """Minimum tempo (in BPM) FL Studio supports.""" VALID_PPQS: Final = (24, 48, 72, 96, 120, 144, 168, 192, 384, 768, 960) """PPQs / timebase supported by FL Studio as of its latest version.""" class TimestampEvent(StructEventBase): STRUCT = c.Struct("created_on" / c.Float64l, "time_spent" / c.Float64l).compile() @enum.unique class PanLaw(ct.EnumBase): """Used by :attr:`Project.pan_law`.""" Circular = 0 Triangular = 2 @enum.unique class FileFormat(enum.IntEnum): """File formats used by FL Studio. *New in FL Studio v2.5.0*: FST (FL Studio state) file format. """ None_ = -1 """Temporary file.""" Project = 0 """FL Studio project (.flp).""" Score = 0x10 """FL Studio score (.fsc). Stores pattern notes and controller events.""" Automation = 24 """Stores controller events and automation channels as FST.""" ChannelState = 0x20 """Entire channel (including plugin events). Stored as FST.""" PluginState = 0x30 """Events of a native plugin on a channel or insert slot. Stored as FST.""" GeneratorState = 0x31 """Plugins events of a VST instrument. Stored as FST.""" FXState = 0x32 """Plugin events of a VST effect. Stored as FST.""" InsertState = 0x40 """Insert and all its slots. Stored as FST.""" _ProbablyPatcher = 0x50 # * Patcher presets are stored as `PluginState`. class ProjectID(EventEnum): LoopActive = (9, BoolEvent) ShowInfo = (10, BoolEvent) _Volume = (12, U8Event) PanLaw = (23, U8Event) Licensed = (28, BoolEvent) _TempoCoarse = WORD + 2 Pitch = (WORD + 16, I16Event) _TempoFine = WORD + 29 #: 3.4.0+ CurGroupId = (DWORD + 18, I32Event) Tempo = (DWORD + 28, U32Event) FLBuild = (DWORD + 31, U32Event) Title = TEXT + 2 Comments = TEXT + 3 Url = TEXT + 5 _RTFComments = TEXT + 6 #: 1.2.10+ FLVersion = (TEXT + 7, AsciiEvent) Licensee = TEXT + 8 #: 1.3.9+ DataPath = TEXT + 10 #: 9.0+ Genre = TEXT + 14 #: 5.0+ Artists = TEXT + 15 #: 5.0+ Timestamp = (DATA + 29, TimestampEvent) class _ProjectKW(TypedDict): channel_count: int ppq: int format: FileFormat class Project(EventModel): """Represents an FL Studio project.""" def __init__(self, events: EventTree, **kw: Unpack[_ProjectKW]) -> None: super().__init__(events, **kw) def __repr__(self) -> str: return f"" def __str__(self) -> str: return f"FL Studio v{self.version!s} {self.format.name}" # type: ignore @property def arrangements(self) -> Arrangements: """Provides an iterator over arrangements and other related properties.""" arrnew_occured = False def select(e: AnyEvent) -> Literal[True] | None: nonlocal arrnew_occured if e.id == ArrangementID.New: arrnew_occured = True # * Prevents accidentally passing on Pattern's timemarkers # TODO This logic will still be incorrect if arrangement's # timemarkers occur before ArrangementID.New event. if e.id in TimeMarkerID and arrnew_occured: return True if e.id in (*ArrangementID, *ArrangementsID, *TrackID): return True return Arrangements( self.events.subtree(select), channels=self.channels, patterns=self.patterns, version=self.version, ) artists = EventProp[str](ProjectID.Artists) """Authors / artists info. to be embedded in exported WAV & MP3. :menuselection:`Options --> &Project info --> Author` *New in FL Studio v5.0.* """ @property def channel_count(self) -> int: """Number of channels in the rack. For Patcher presets, the total number of plugins used inside it. Raises: ValueError: When a value less than zero is tried to be set. """ return self._kw["channel_count"] @channel_count.setter def channel_count(self, value: int) -> None: if value < 0: raise ValueError("Channel count cannot be less than zero") self._kw["channel_count"] = value @property def channels(self) -> ChannelRack: """Provides an iterator over channels and channel rack properties.""" def select(e: AnyEvent) -> bool | None: if e.id == InsertID.Flags: return False if e.id in (*ChannelID, *DisplayGroupID, *PluginID, *RackID): return True return ChannelRack( self.events.subtree(select), channel_count=self.channel_count, ) comments = EventProp[str](ProjectID.Comments, ProjectID._RTFComments) """Comments / project description / summary. :menuselection:`Options --> &Project info --> Comments` Caution: Very old versions of FL used to store comments in RTF (Rich Text Format). PyFLP makes no efforts to parse that and stores it like a normal string as it is. It is upto you to extract the text out of it. """ # Stored as a duration in days since the Delphi epoch (30 Dec, 1899). @property def created_on(self) -> datetime.datetime | None: """The local date and time on which this project was created. Located at the bottom of :menuselection:`Options --> &Project info` page. """ if ProjectID.Timestamp in self.events.ids: event = cast(TimestampEvent, self.events.first(ProjectID.Timestamp)) return _DELPHI_EPOCH + datetime.timedelta(days=event["created_on"]) format = KWProp[FileFormat]() """Internal format marker used by FL Studio to distinguish between types.""" @property def data_path(self) -> pathlib.Path | None: """The absolute path used by FL to store all your renders. :menuselection:`Options --> &Project general settings --> Data folder` *New in FL Studio v9.0.0.* """ if ProjectID.DataPath in self.events.ids: return pathlib.Path(self.events.first(ProjectID.DataPath).value) @data_path.setter def data_path(self, value: str | pathlib.Path) -> None: if ProjectID.DataPath not in self.events.ids: raise PropertyCannotBeSet(ProjectID.DataPath) if isinstance(value, pathlib.Path): value = str(value) path = "" if value == "." else value self.events.first(ProjectID.DataPath).value = path genre = EventProp[str](ProjectID.Genre) """Genre of the song to be embedded in exported WAV & MP3. :menuselection:`Options --> &Project info --> Genre` *New in FL Studio v5.0*. """ licensed = EventProp[bool](ProjectID.Licensed) """Whether the project was last saved with a licensed copy of FL Studio. Tip: Setting this to `True` and saving back the FLP will make it load the next time in a trial version of FL if it wouldn't open before. """ # Internally, this is jumbled up. Thanks to @codecat/libflp for decode algo. @property def licensee(self) -> str | None: """The license holder's username who last saved the project file. If saved with a trial version this is empty. Tip: As of the latest version, FL doesn't check for the contents of this for deciding whether to open or not when in trial version. *New in FL Studio v1.3.9*. """ if ProjectID.Licensee in self.events.ids: event = self.events.first(ProjectID.Licensee) licensee = bytearray() for idx, char in enumerate(event.value): c1 = ord(char) - 26 + idx c2 = ord(char) + 49 + idx for num in c1, c2: if chr(num).isalnum(): licensee.append(num) break return licensee.decode("ascii") @licensee.setter def licensee(self, value: str) -> None: if self.version < FLVersion(1, 3, 9): pass if ProjectID.Licensee not in self.events.ids: raise PropertyCannotBeSet(ProjectID.Licensee) event = self.events.first(ProjectID.Licensee) licensee = bytearray() for idx, char in enumerate(value): c1 = ord(char) + 26 - idx c2 = ord(char) - 49 - idx for cp in c1, c2: if 0 < cp <= 127: licensee.append(cp) break event.value = licensee.decode("ascii") looped = EventProp[bool](ProjectID.LoopActive) """Whether a portion of the playlist is selected.""" main_pitch = EventProp[int](ProjectID.Pitch) """:guilabel:`Master pitch` (in cents). Min = -1200. Max = +1200. Defaults to 0.""" main_volume = EventProp[int](ProjectID._Volume) """*Changed in FL Studio v1.7.6*: Can be upto 125% (+5.6dB) now.""" @property def mixer(self) -> Mixer: """Provides an iterator over inserts and other mixer related properties.""" inserts_began = False def select(e: AnyEvent) -> Literal[True] | None: nonlocal inserts_began if e.id in (*MixerID, *InsertID, *SlotID): # TODO Find a more reliable to detect when inserts start. inserts_began = True return True if inserts_began and e.id in PluginID: return True return Mixer(self.events.subtree(select), version=self.version) @property def patterns(self) -> Patterns: """Returns a collection of patterns and other related properties.""" arrnew_occured = False def select(e: AnyEvent) -> Literal[True] | None: nonlocal arrnew_occured if e.id == ArrangementID.New: arrnew_occured = True # * Prevents accidentally passing on Arrangement's timemarkers elif e.id in TimeMarkerID and not arrnew_occured: return True elif e.id in (*PatternID, *PatternsID): return True return Patterns(self.events.subtree(select)) pan_law = EventProp[PanLaw](ProjectID.PanLaw) """Whether a circular or a triangular pan law is used for the project. :menuselection:`Options -> &Project general settings -> Advanced -> Panning law` """ @property def ppq(self) -> int: """Pulses per quarter. ![](https://bit.ly/3F0UrMT) :menuselection:`Options --> &Project general settings --> Timebase (PPQ)`. Note: All types of lengths, positions and offsets internally use the PPQ as a multiplying factor. Danger: Don't try to set this property, it affects all the length, position and offset calculations used for deciding the placement of playlist, automations, timemarkers and patterns. When you change this in FL, it recalculates all the above. It is beyond PyFLP's scope to properly recalculate the timings. Raises: ValueError: When a value not in ``VALID_PPQS`` is tried to be set. *Changed in FL Studio v2.1.1*: Defaults to ``96``. """ return self._kw["ppq"] @ppq.setter def ppq(self, value: int) -> None: if value not in VALID_PPQS: raise ValueError(f"Expected one of {VALID_PPQS}; got {value} instead") self._kw["ppq"] = value show_info = EventProp[bool](ProjectID.ShowInfo) """Whether to show a banner while the project is loading inside FL Studio. :menuselection:`Options --> &Project info --> Show info on opening` The banner shows the :attr:`title`, :attr:`artists`, :attr:`genre`, :attr:`comments` and :attr:`url`. """ title = EventProp[str](ProjectID.Title) """Name of the song / project. :menuselection:`Options --> &Project info --> Title` """ # Stored internally as the actual BPM * 1000 as an integer. @property def tempo(self) -> int | float | None: """Tempo at the current position of the playhead (in BPM). ![](https://bit.ly/3MKdAEO) Raises: TypeError: When a fine-tuned tempo (``float``) isn't supported. Use an ``int`` (coarse tempo) value. PropertyCannotBeSet: If underlying event isn't found. ValueError: When a tempo outside the allowed range is set. * *Changed in FL Studio v1.4.2*: Max tempo increased to ``999`` (int). * *New in FL Studio v3.4.0*: Fine tuned tempo (a float). * *Changed in FL Studio v11*: Max tempo limited to ``522.000``. Probably when tempo automations """ if ProjectID.Tempo in self.events.ids: return self.events.first(ProjectID.Tempo).value / 1000 tempo = None if ProjectID._TempoCoarse in self.events.ids: tempo = self.events.first(ProjectID._TempoCoarse).value if ProjectID._TempoFine in self.events.ids: tempo += self.events.first(ProjectID._TempoFine).value / 1000 return tempo @tempo.setter def tempo(self, value: int | float) -> None: if self.tempo is None: raise PropertyCannotBeSet(ProjectID.Tempo, ProjectID._TempoCoarse, ProjectID._TempoFine) max_tempo = 999.0 if FLVersion(1, 4, 2) <= self.version < FLVersion(11) else 522.0 if isinstance(value, float) and self.version < FLVersion(3, 4, 0): raise TypeError("Expected an 'int' object got a 'float' instead") if float(value) > max_tempo or float(value) < MIN_TEMPO: raise ValueError(f"Invalid tempo {value}; expected {MIN_TEMPO}-{max_tempo}") if ProjectID.Tempo in self.events.ids: self.events.first(ProjectID.Tempo).value = int(value * 1000) if ProjectID._TempoFine in self.events.ids: tempo_fine = int((value - math.floor(value)) * 1000) self.events.first(ProjectID._TempoFine).value = tempo_fine if ProjectID._TempoCoarse in self.events.ids: self.events.first(ProjectID._TempoCoarse).value = math.floor(value) @property def time_spent(self) -> datetime.timedelta | None: """Time spent on the project since its creation. ![](https://bit.ly/3TsBzdM) Located at the bottom of :menuselection:`Options --> &Project info` page. """ if ProjectID.Timestamp in self.events.ids: event = cast(TimestampEvent, self.events.first(ProjectID.Timestamp)) return datetime.timedelta(days=event["time_spent"]) url = EventProp[str](ProjectID.Url) """:menuselection:`Options --> &Project info --> Web link`.""" # Internally represented as a string with a format of # `major.minor.patch.build?` *where `build` is optional, since older # versions of FL didn't follow the same versioning scheme*. # # To maintain backward compatibility with FL Studio prior to v11.5 which # stored strings in ASCII, this event is always stored with ASCII data, # even if the rest of the strings use Windows Unicode (UTF16). @property def version(self) -> FLVersion: """The version of FL Studio which was used to save the file. ![](https://bit.ly/3TD3BU0) Located at the top of :menuselection:`Help --> &About` page. Caution: Changing this to a lower version will not make a file load magically inside FL Studio, as newer events and/or plugins might have been used. Raises: PropertyCannotBeSet: This error should NEVER occur; if it does, it indicates possible corruption. ValueError: When a string with an invalid format is tried to be set. """ event = cast(AsciiEvent, self.events.first(ProjectID.FLVersion)) return FLVersion(*tuple(int(part) for part in event.value.split("."))) @version.setter def version(self, value: FLVersion | str | tuple[int, ...]) -> None: if ProjectID.FLVersion not in self.events.ids: raise PropertyCannotBeSet(ProjectID.FLVersion) if isinstance(value, FLVersion): parts = [value.major, value.minor, value.patch] if value.build is not None: parts.append(value.build) elif isinstance(value, str): parts = [int(part) for part in value.split(".")] else: parts = list(value) if len(parts) < 3 or len(parts) > 4: raise ValueError("Expected format: major.minor.build.patch?") version = ".".join(str(part) for part in parts) self.events.first(ProjectID.FLVersion).value = version if len(parts) == 4 and ProjectID.FLBuild in self.events.ids: self.events.first(ProjectID.FLBuild).value = parts[3] ================================================ FILE: pyflp/py.typed ================================================ ================================================ FILE: pyflp/timemarker.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2022 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . """Contains the types required for pattern and playlist timemarkers.""" from __future__ import annotations import enum from pyflp._descriptors import EventProp from pyflp._events import DWORD, TEXT, EventEnum, U8Event, U32Event from pyflp._models import EventModel, ModelReprMixin __all__ = ["TimeMarkerID", "TimeMarkerType", "TimeMarker"] @enum.unique class TimeMarkerID(EventEnum): Numerator = (33, U8Event) Denominator = (34, U8Event) Position = (DWORD + 20, U32Event) Name = TEXT + 13 class TimeMarkerType(enum.IntEnum): Marker = 0 """Normal text marker.""" Signature = 134217728 """Used for time signature markers.""" class TimeMarker(EventModel, ModelReprMixin): """A marker in the timeline of an :class:`Arrangement`. ![](https://bit.ly/3gltKbt) """ def __str__(self) -> str: if self.type == TimeMarkerType.Marker: if self.name: return f"Marker {self.name!r} @ {self.position!r}" return f"Unnamed marker @ {self.position!r}" time_sig = f"{self.numerator}/{self.denominator}" if self.name: return f"Signature {self.name!r} ({time_sig}) @ {self.position!r}" return f"Unnamed {time_sig} signature @ {self.position!r}" denominator: EventProp[int] = EventProp[int](TimeMarkerID.Denominator) name = EventProp[str](TimeMarkerID.Name) numerator = EventProp[int](TimeMarkerID.Numerator) @property def position(self) -> int | None: if TimeMarkerID.Position in self.events.ids: event = self.events.first(TimeMarkerID.Position) if event.value < TimeMarkerType.Signature: return event.value return event.value - TimeMarkerType.Signature @property def type(self) -> TimeMarkerType | None: """The action with which a time marker is associated. [![](https://bit.ly/3RDM1yn)]() """ if TimeMarkerID.Position in self.events.ids: event = self.events.first(TimeMarkerID.Position) if event.value >= TimeMarkerType.Signature: return TimeMarkerType.Signature return TimeMarkerType.Marker ================================================ FILE: pyflp/types.py ================================================ # PyFLP - An FL Studio project file (.flp) parser # Copyright (C) 2023 demberto # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) # any later version. This program is distributed in the hope that it will be # useful, but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the # GNU General Public License along with this program. If not, see # . from __future__ import annotations import enum from collections import UserDict, UserList from dataclasses import dataclass from typing import Any, NamedTuple, TypeVar, Union, TYPE_CHECKING import construct import construct_typed as ct from typing_extensions import ParamSpec, TypeAlias P = ParamSpec("P") T = TypeVar("T") U = TypeVar("U") ET = TypeVar("ET", bound=Union[ct.EnumBase, enum.IntFlag]) T_co = TypeVar("T_co", covariant=True) @dataclass(frozen=True, order=True) class FLVersion: major: int minor: int = 0 patch: int = 0 build: int | None = None def __str__(self) -> str: version = f"{self.major}.{self.minor}.{self.patch}" if self.build is not None: return f"{version}.{self.build}" return version class MusicalTime(NamedTuple): bars: int """1 bar == 16 beats == 768 (internal representation).""" beats: int """1 beat == 240 ticks == 48 (internal representation).""" ticks: int """5 ticks == 1 (internal representation).""" class RGBA(NamedTuple): red: float green: float blue: float alpha: float @staticmethod def from_bytes(buf: bytes) -> RGBA: return RGBA(*(c / 255 for c in buf)) def __bytes__(self) -> bytes: return bytes(round(c * 255) for c in self) if TYPE_CHECKING: AnyContainer: TypeAlias = construct.Container[Any] AnyListContainer: TypeAlias = construct.ListContainer[Any] AnyDict: TypeAlias = UserDict[str, Any] AnyList: TypeAlias = UserList[AnyContainer] else: AnyContainer = construct.Container AnyListContainer = construct.ListContainer AnyDict = UserDict AnyList = UserList ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools>=61.0.0", "setuptools_scm[toml]>=6.2"] build-backend = "setuptools.build_meta" [project] name = "pyflp" authors = [{ name = "demberto", email = "demberto@protonmail.com" }] description = "FL Studio project file parser" readme = "README.md" requires-python = ">=3.8" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Multimedia", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] license = { text = "GPL-3.0" } dependencies = [ "f-enum>=0.2.0;python_version<='3.10'", "construct-typing>=0.5.6", "sortedcontainers>=2.4.0", "typing_extensions>=4.6.1", ] dynamic = ["version"] [project.optional-dependencies] dev = [ "coverage >=7.2.6", "pre-commit >= 3.3.2", "pytest >=7.3.1", "tox >=4.5.1", ] # for docs dependencies see docs/requirements.txt [project.urls] Source = "https://github.com/demberto/PyFLP" Changelog = "https://github.com/demberto/PyFLP/blob/master/CHANGELOG.md" Documentation = "https://pyflp.rtfd.io" "Bug Tracker" = "https://github.com/demberto/PyFLP/issues" [tool.black] line-length = 100 [tool.coverage.run] branch = true parallel = true omit = ["main.py"] [tool.coverage.report] exclude_lines = [ "pragma: no cover", # Have to re-enable the standard pragma "def __repr__", "\\.\\.\\.", # Ellipsis operator used in protocols "if owner is None:", # Descriptor __get__() checks "@(abc\\.)?abstractmethod", # "@abc.abstractmethod" or "@abstractmethod" ] ignore_errors = true [tool.isort] profile = "black" line-length = 100 [tool.mypy] python_version = "3.8" check_untyped_defs = true enable_incomplete_feature = ["Unpack"] ignore_missing_imports = true warn_no_return = false [tool.pyright] reportPrivateUsage = false reportMissingTypeStubs = false [tool.pytest.ini_options] minversion = "6.0" addopts = "-ra -q" testpaths = "tests" [tool.ruff] target-version = "py38" line-length = 100 [tool.setuptools] packages = ["pyflp"] [tool.setuptools_scm] write_to = "pyflp/_version.py" ================================================ FILE: requirements.txt ================================================ construct-typing==0.5.6 f-enum==0.2.0;python_version<="3.10" sortedcontainers==2.4.0 typing_extensions==4.7.1 ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ from __future__ import annotations import pathlib from typing import TypeVar import pytest import pyflp from pyflp import Project from pyflp._events import EventEnum from pyflp._models import ModelBase from pyflp.mixer import Mixer MT = TypeVar("MT", bound=ModelBase) @pytest.fixture(scope="session") def project(): return pyflp.parse(pathlib.Path(__file__).parent / "assets" / "FL 20.8.4.flp") @pytest.fixture(scope="session") def arrangements(project: Project): return project.arrangements @pytest.fixture(scope="session") def rack(project: Project): return project.channels @pytest.fixture(scope="session") def mixer(project: Project): return project.mixer @pytest.fixture(scope="session") def inserts(mixer: Mixer): return tuple(mixer)[:25] @pytest.fixture(scope="session") def patterns(project: Project): return project.patterns def get_model(suffix: str, type: type[MT], *only: EventEnum) -> MT: parsed = pyflp.parse(pathlib.Path(__file__).parent / "assets" / suffix) if only: return type(parsed.events.subtree(lambda e: e.id in only)) return type(parsed.events) ================================================ FILE: tests/test_arrangement.py ================================================ from __future__ import annotations from typing import Callable import pytest from pyflp._events import RGBA from pyflp.arrangement import ( Arrangement, Arrangements, ChannelPLItem, PatternPLItem, Track, TrackMotion, TrackPress, TrackSync, ) def test_arrangements(arrangements: Arrangements): assert len(arrangements) == 2 assert arrangements.current == arrangements[0] assert arrangements.loop_pos == (3840, 5376) assert arrangements.max_tracks == 500 assert arrangements.time_signature.num == 4 assert arrangements.time_signature.beat == 4 @pytest.fixture(scope="session") def arrangement(arrangements: Arrangements): def wrapper(index: int): return arrangements[index] return wrapper @pytest.fixture(scope="session") def tracks(arrangement: Callable[[int], Arrangement]): return tuple(arrangement(0).tracks)[:22] def test_track_color(tracks: tuple[Track, ...]): for track in tracks: assert ( track.color == RGBA(1.0, 0.0, 0.0, 0.0) if track.name == "Red" else track.color == RGBA.from_bytes(bytes((72, 81, 86, 0))) ) def test_track_content_locked(tracks: tuple[Track, ...]): for track in tracks: assert ( track.content_locked if track.name == "Locked to content" else not track.content_locked ) def test_track_enabled(tracks: tuple[Track, ...]): for track in tracks: assert not track.enabled if track.name == "Disabled" else track.enabled def test_track_grouped(tracks: tuple[Track, ...]): for track in tracks: assert track.grouped if track.name == "Grouped" else not track.grouped def test_track_height(tracks: tuple[Track, ...]): for track in tracks: if track.name == "Min Size": assert track.height == "0%" elif track.name == "Max Size": assert track.height == "1000%" else: assert track.height == "100%" def test_track_icon(tracks: tuple[Track, ...]): for track in tracks: assert track.icon == 70 if track.name == "Iconified" else not track.icon def test_track_items(tracks: tuple[Track, ...]): for track in tracks: num_items = 0 if track.name == "Audio track": num_items = 16 assert {type(i) for i in track} == {ChannelPLItem} assert {i.channel.iid for i in track} == {11} # type: ignore elif track.name == "MIDI": num_items = 4 assert {type(i) for i in track} == {PatternPLItem} assert {i.pattern.iid for i in track} == {3} # type: ignore assert [i.position for i in track] == [p * 384 for p in range(num_items)] elif track.name in ("Cut pattern", "Automation"): num_items = 1 assert len(track) == num_items assert [i.group for i in track] == [0] * num_items def test_track_locked(tracks: tuple[Track, ...]): for track in tracks: assert track.locked if track.name == "Locked" else not track.locked def test_track_motion(tracks: tuple[Track, ...]): for track in tracks: assert ( track.motion == TrackMotion.Random if track.name == "Random Motion" else track.motion == TrackMotion.Stay ) def test_track_name(tracks: tuple[Track, ...]): assert [track.name for track in tracks] == [ None, "Enabled", "Disabled", "Locked", "Red", "Iconified", "Grouped", "Audio track", "Instrument track", "MIDI", "Cut pattern", "Automation", "Locked to content", "Locked to size", "Min Size", "Max Size", "Latched", "Random Motion", "Trigger Sync OFF", "Position Sync AUTO", "Queued", "Intolerant", ] def test_track_position_sync(tracks: tuple[Track, ...]): for track in tracks: assert ( track.position_sync == TrackSync.Auto if track.name == "Position Sync AUTO" else track.position_sync == TrackSync.Off ) def test_track_press(tracks: tuple[Track, ...]): for track in tracks: assert ( track.press == TrackPress.Latch if track.name == "Latched" else track.press == TrackPress.Retrigger ) def test_track_tolerant(tracks: tuple[Track, ...]): for track in tracks: assert not track.tolerant if track.name == "Intolerant" else track.tolerant def test_track_queued(tracks: tuple[Track, ...]): for track in tracks: assert track.queued if track.name == "Queued" else not track.queued def test_first_arrangement(arrangement: Callable[[int], Arrangement]): arr = arrangement(0) assert arr.name == "Just tracks" assert not tuple(arr.timemarkers) assert len(tuple(arr.tracks)) == 500 def test_second_arrangement(arrangement: Callable[[int], Arrangement]): arr = arrangement(1) assert arr.name == "Just timemarkers" assert len(tuple(arr.timemarkers)) == 11 assert len(tuple(arr.tracks)) == 500 ================================================ FILE: tests/test_channel.py ================================================ from __future__ import annotations import pathlib from typing import TypeVar from pyflp._events import RGBA from pyflp.channel import ( Automation, Channel, ChannelRack, DeclickMode, FilterType, Instrument, Layer, LFOShape, ReverbType, Sampler, StretchMode, ) from pyflp.project import Project from .conftest import get_model CT = TypeVar("CT", bound=Channel) def _load_channel(preset: str, type: type[CT]): return get_model(f"channels/{preset}", type) # This is separated only to pass type checks # (preset: str, type: type[CT] = Channel) -> CT messes inferred return type def load_channel(preset: str): return _load_channel(preset, Channel) def load_automation(preset: str): return _load_channel(preset, Automation) def load_instrument(preset: str): return _load_channel(preset, Instrument) def load_layer(preset: str): return _load_channel(preset, Layer) def load_sampler(preset: str): return _load_channel(preset, Sampler) def test_channels(project: Project, rack: ChannelRack): assert len(rack) == project.channel_count assert rack.fit_to_steps is None assert rack.height == 646 assert [group.name for group in rack.groups] == ["Audio", "Generators", "Unsorted"] assert not rack.swing def test_automation_lfo(): lfo = load_automation("automation-lfo.fst").lfo assert lfo.amount == 64 def test_automation_points(): points = [point for point in load_automation("automation-points.fst")] assert [int(p.position or 0) for p in points] == [0, 8, 8, 16, 24, 32] def test_channel_color(): assert load_channel("colored.fst").color == RGBA.from_bytes(bytes((20, 20, 255, 0))) def test_channel_enabled(): assert not load_channel("disabled.fst").enabled def test_channel_group(rack: ChannelRack): for channel in rack: if channel.name == "22in Kick": assert channel.group.name == "Audio" elif channel.display_name in ("BooBass", "Fruit Kick", "Plucked!"): assert channel.group.name == "Generators" else: assert channel.group.name == "Unsorted" def test_channel_icon(): assert load_channel("iconified.fst").icon == 116 def test_channel_pan(): assert load_channel(r"100%-left.fst").pan == 0 assert load_channel(r"100%-right.fst").pan == 12800 def test_channel_volume(): assert load_channel("full-volume.fst").volume == 12800 assert not load_channel("zero-volume.fst").volume def test_channel_zipped(rack: ChannelRack): for channel in rack: if channel.name == "Zipped": assert channel.zipped else: assert not channel.zipped def test_instrument_delay(): delay = load_instrument("delay.fst").delay assert delay.feedback == 12800 assert delay.echoes == 10 assert delay.fat_mode assert delay.mod_x == 0 assert delay.mod_y == 256 assert delay.pan == -6400 assert delay.ping_pong assert delay.time == 144 def test_instrument_keyboard(): keyboard = load_instrument("keyboard.fst").keyboard assert keyboard.add_root assert keyboard.fine_tune == 100 assert keyboard.key_region == (48, 72) assert keyboard.main_pitch assert keyboard.root_note == 60 def test_instrument_polyphony(): polyphony = load_instrument("polyphony.fst").polyphony assert polyphony.mono assert polyphony.porta assert polyphony.max == 4 assert polyphony.slide == 820 def test_instrument_routing(): assert load_instrument("routed.fst").insert == 125 def test_instrument_time(): time = load_instrument("time.fst").time assert time.full_porta assert time.gate == 450 assert time.shift == 1024 assert time.swing == 64 def test_instrument_tracking(): tracking = load_instrument("tracking.fst").tracking assert tracking and len(tracking) == 2 key_tracking = tracking["keyboard"] assert key_tracking.middle_value == 84 assert key_tracking.mod_x == -256 assert key_tracking.mod_y == 256 assert key_tracking.pan == 256 # ! Apparently, layer children events aren't stored in presets # def test_layer_children(): pass def test_layer_crossfade(): assert load_layer("layer-crossfade.fst").crossfade def test_layer_random(): assert load_layer("layer-random.fst").random def test_sampler_content(): content = load_sampler("sampler-content.fst").content assert content.keep_on_disk assert content.resample assert not content.load_regions assert not content.load_slices assert content.declick_mode == DeclickMode.Generic def test_sampler_cut_group(): assert load_sampler("cut-groups.fst").cut_group == (1, 2) def test_sampler_envelopes(): envelopes = load_sampler("envelope.fst").envelopes assert envelopes and len(envelopes) == 5 volume = envelopes["Volume"] assert volume.enabled assert volume.predelay == 100 assert volume.attack == 100 assert volume.hold == 100 assert volume.decay == 100 assert volume.sustain == 0 assert volume.release == 100 assert volume.synced assert volume.attack_tension == volume.release_tension == volume.decay_tension == 0 mod_x = envelopes["Mod X"] assert mod_x.enabled assert mod_x.predelay == 65536 assert mod_x.attack == 65536 assert mod_x.hold == 65536 assert mod_x.decay == 65536 assert mod_x.sustain == 128 assert mod_x.release == 65536 assert mod_x.amount == 128 assert not mod_x.synced assert mod_x.attack_tension == mod_x.release_tension == mod_x.decay_tension == 128 def test_sampler_filter(): filter = load_sampler("sampler-filter.fst").filter assert filter.mod_x == 0 assert filter.mod_y == 256 assert filter.type == FilterType.SVFLPx2 def test_sampler_fx(): fx = load_sampler("sampler-fx.fst").fx assert fx.boost == 128 assert fx.clip assert fx.cutoff == 16 assert fx.crossfade == 0 assert fx.fade_in == 1024 assert fx.fade_out == 0 assert fx.fade_stereo assert fx.fix_trim assert fx.freq_tilt == 0 assert fx.length == 1.0 assert not fx.normalize assert fx.pogo == 256 assert fx.inverted assert not fx.remove_dc assert fx.resonance == 640 assert fx.reverb.type == ReverbType.A assert fx.reverb.mix == 128 assert not fx.reverse assert fx.ringmod == (64, 192) assert fx.start == 0.0 assert fx.stereo_delay == 4096 assert fx.swap_stereo assert fx.trim == 256 def test_sampler_lfo(): lfos = load_sampler("lfo.fst").lfos assert lfos and len(lfos) == 5 volume = lfos["Volume"] assert volume.amount == 128 assert volume.attack == 65536 assert volume.predelay == 100 assert volume.shape == LFOShape.Pulse assert volume.speed == 65536 assert volume.retrig assert not volume.synced mod_x = lfos["Mod X"] assert mod_x.amount == -128 assert mod_x.attack == 100 assert mod_x.predelay == 65536 assert mod_x.shape == LFOShape.Sine assert mod_x.speed == 200 assert not mod_x.retrig assert mod_x.synced def test_sampler_path(): assert load_sampler("sampler-path.fst").sample_path == pathlib.Path( r"%FLStudioFactoryData%\Data\Patches\Packs\Drums\Kicks\22in Kick.wav" ) def test_sampler_pitch_shift(): assert load_sampler("+4800-cents.fst").pitch_shift == 4800 assert load_sampler("-4800-cents.fst").pitch_shift == -4800 def test_sampler_playback(): playback = load_sampler("sampler-playback.fst").playback assert playback.use_loop_points assert playback.ping_pong_loop assert playback.start_offset == 1072693248 def test_sampler_stretching(): stretching = load_sampler("sampler-stretching.fst").stretching assert stretching.mode == StretchMode.E3Generic assert stretching.multiplier == 0.25 assert stretching.pitch == 1200 assert stretching.time == (4, 0, 0) ================================================ FILE: tests/test_corrupted.py ================================================ from __future__ import annotations import pathlib import pytest import pyflp from pyflp.exceptions import HeaderCorrupted CORRUPTED = pathlib.Path(__file__).parent / "assets" / "corrupted" def test_invalid_header_magic(): with pytest.raises(HeaderCorrupted, match="FLhd"): pyflp.parse(CORRUPTED / "invalid-header-magic.flp") def test_invalid_header_size(): with pytest.raises(HeaderCorrupted, match="6"): pyflp.parse(CORRUPTED / "invalid-header-size.flp") def test_invalid_format(): with pytest.raises(HeaderCorrupted, match="Unsupported project file format"): pyflp.parse(CORRUPTED / "invalid-format.flp") def test_invalid_ppq(): with pytest.raises(HeaderCorrupted, match="Invalid PPQ"): # ! Opening this FLP in FL will crash it with a division by zero error pyflp.parse(CORRUPTED / "invalid-ppq.flp") def test_invalid_data_magic(): with pytest.raises(HeaderCorrupted, match="FLdt"): pyflp.parse(CORRUPTED / "invalid-data-magic.flp") def test_invalid_data_size(): with pytest.raises(HeaderCorrupted, match="Data chunk size corrupted"): pyflp.parse(CORRUPTED / "invalid-event-size.flp") ================================================ FILE: tests/test_events.py ================================================ from __future__ import annotations import pytest from pyflp._events import AsciiEvent, EventEnum, EventTree, U8Event, WORD from pyflp.exceptions import EventIDOutOfRange, InvalidEventChunkSize def test_id_out_of_range(): with pytest.raises(EventIDOutOfRange, match=str(tuple(range(0, WORD)))): U8Event(EventEnum(128), b"\x00") with pytest.raises(ValueError): AsciiEvent(EventEnum(0), b"1234-decode-me-baby") def test_invalid_chunk_size(): with pytest.raises(InvalidEventChunkSize, match="1"): U8Event(EventEnum(0), b"12") def test_event_tree(): root = EventTree() child = EventTree(root) assert child in root.children event = U8Event(EventEnum(0), b"\x01") child.append(event) assert root.first(EventEnum(0)) == event child.remove(EventEnum(0)) assert not root ================================================ FILE: tests/test_mixer.py ================================================ from __future__ import annotations from typing import cast from pyflp._events import RGBA from pyflp.mixer import Insert, InsertDock, Mixer, MixerID, MixerParamsEvent from .conftest import get_model def get_insert(preset: str): # Parse as Mixer to get events, because an Insert cannot parse # MixerID.Params which holds most of its information. mixer = get_model(f"inserts/{preset}", Mixer) # A preset stores items only for a single insert, currently thats 32 per # insert. Pass these to Insert's constructor. This mimics Mixer's normal # behaviour, however that depends on InsertID.Output as a marker to indicate # the end of an Insert, which surprisingly isn't a part of presets. params = cast(MixerParamsEvent, mixer.events.first(MixerID.Params)) items = tuple(params.items_.values())[0] return Insert(mixer.events, iid=0, max_slots=10, params=items) def test_insert_bypassed(): assert get_insert("effects-bypassed.fst").bypassed def test_insert_channels_swapped(): assert get_insert("channels-swapped.fst").channels_swapped def test_insert_color(): assert get_insert("colored.fst").color == RGBA.from_bytes(bytes((255, 20, 20, 0))) def test_insert_dock(inserts: tuple[Insert, ...]): sends = (101, 102, 103, 104) for insert in inserts: if insert.name in ("Docked left", "Master"): assert insert.dock == InsertDock.Left elif insert.name == "Docked right" or insert.iid in sends: assert insert.dock == InsertDock.Right else: assert insert.dock == InsertDock.Middle def test_insert_enabled(): assert not get_insert("disabled.fst").enabled def test_insert_locked(): assert get_insert("locked.fst").locked def test_insert_pan(): assert get_insert(r"100%-left.fst").pan == -6400 assert get_insert(r"100%-right.fst").pan == 6400 def test_insert_polarity_reversed(): assert get_insert("polarity-reversed.fst").polarity_reversed def test_insert_routes(inserts: tuple[Insert, ...]): assert not tuple(inserts[5].routes) def test_insert_stereo_separation(): assert get_insert(r"100%-merged.fst").stereo_separation == 64 assert get_insert(r"100%-separated.fst").stereo_separation == -64 def test_insert_eq(): eq = get_insert("post-eq.fst").eq assert eq.low.freq == 0 assert eq.low.gain == 1800 assert eq.low.reso == 0 assert eq.mid.freq == 33145 assert eq.mid.gain == 0 assert eq.mid.reso == 17500 assert eq.high.freq == 65536 assert eq.high.gain == -1800 assert eq.high.reso == 65536 def test_mixer(mixer: Mixer): assert mixer.apdc assert len(mixer) == mixer.max_inserts == 127 assert mixer.max_slots == 10 ================================================ FILE: tests/test_models.py ================================================ from __future__ import annotations from pyflp.types import FLVersion def test_flversion(): assert str(FLVersion(20, 8, 4)) == "20.8.4" assert str(FLVersion(20, 8, 4, 2576)) == "20.8.4.2576" ================================================ FILE: tests/test_pattern.py ================================================ from __future__ import annotations from pyflp._events import RGBA from pyflp.pattern import Pattern, PatternID, Patterns from .conftest import get_model def get_notes(score: str): return tuple(get_model(f"patterns/{score}", Pattern, *PatternID).notes) def test_patterns(patterns: Patterns): assert len(patterns) == 5 assert patterns.current == patterns[4] assert patterns.play_cut_notes def test_pattern_color(patterns: Patterns): assert patterns[2].color == RGBA(0.0, 1.0, 0.0, 0.0) def test_pattern_names(patterns: Patterns): assert {pattern.name for pattern in patterns} == { "Default", "Colored", "MIDI", "Timemarkers", "Selected", } def test_pattern_timemarkers(patterns: Patterns): assert len(tuple(patterns["Timemarkers"].timemarkers)) == 5 def test_empty_pattern(): assert not len(get_notes("empty.fsc")) def test_note_color(): assert get_notes("color-9.fsc")[0].midi_channel == 8 def test_note_fine_pitch(): assert [n.fine_pitch for n in get_notes("fine-pitch-min-max.fsc")] == [0, 240] def test_note_group(): assert [n.group for n in get_notes("common-group.fsc")] == [1, 1] def test_note_length(): assert get_notes("c5-1bar.fsc")[0].length == 384 def test_note_mod_x(): assert [n.mod_x for n in get_notes("modx-min-max.fsc")] == [255, 0] def test_note_mod_y(): assert [n.mod_y for n in get_notes("mody-min-max.fsc")] == [0, 255] def test_note_key(): c_major = ["C5", "D5", "E5", "F5", "G5", "A5", "B5", "C6"] assert [n.key for n in get_notes("c-major-scale.fsc")] == c_major def test_note_pan(): assert [n.pan for n in get_notes("pan-min-max.fsc")] == [128, 0] def test_note_position(): notes = get_notes("c-major-scale.fsc") assert [n.position for n in notes] == [x * 384 for x in range(8)] def test_note_rack_channel(): assert {n.rack_channel for n in get_notes("multi-channel.flp")} == {0, 1} def test_note_release(): assert [n.release for n in get_notes("release-min-max.fsc")] == [0, 128] def test_note_slide(): assert get_notes("slide-note.fsc")[0].slide def test_note_velocity(): assert [n.velocity for n in get_notes("velocity-min-max.fsc")] == [0, 128] ================================================ FILE: tests/test_plugin.py ================================================ from __future__ import annotations from typing import TypeVar from pyflp.plugin import ( AnyPlugin, BooBass, FruitKick, FruityBalance, FruityBloodOverdrive, FruityCenter, FruityFastDist, FruitySend, FruitySoftClipper, FruityStereoEnhancer, Plucked, PluginID, Soundgoodizer, VSTPlugin, WrapperPage, ) from .conftest import get_model T = TypeVar("T", bound=AnyPlugin) def get_plugin(preset_file: str, type: type[T]): return get_model(f"plugins/{preset_file}", type, PluginID.Data, PluginID.Wrapper) def test_boobass(): boobass = get_plugin("boobass.fst", BooBass) assert boobass.bass == boobass.mid == boobass.high == 32767 def test_fruit_kick(): fruit_kick = get_plugin("fruit-kick.fst", FruitKick) assert fruit_kick.max_freq == -876 assert fruit_kick.min_freq == 75 assert fruit_kick.freq_decay == 163 assert fruit_kick.amp_decay == 208 assert fruit_kick.click == 39 assert fruit_kick.distortion == 62 def test_fruity_balance(): fruity_balance = get_plugin("fruity-balance.fst", FruityBalance) assert fruity_balance.volume == 256 assert fruity_balance.pan == 0 def test_fruity_blood_overdrive(): fruity_blood_overdrive = get_plugin("fruity-blood-overdrive.fst", FruityBloodOverdrive) assert fruity_blood_overdrive.pre_band == 0 assert fruity_blood_overdrive.color == 5000 assert fruity_blood_overdrive.pre_amp == 0 assert fruity_blood_overdrive.x100 == 0 assert fruity_blood_overdrive.post_filter == 0 def test_fruity_center(): fruity_center = get_plugin("fruity-center.fst", FruityCenter) assert not fruity_center.enabled def test_fruity_fast_dist(): fruity_fast_dist = get_plugin("fruity-fast-dist.fst", FruityFastDist) assert fruity_fast_dist.pre == 128 assert fruity_fast_dist.threshold == 10 assert fruity_fast_dist.kind == "A" assert fruity_fast_dist.mix == 128 assert fruity_fast_dist.post == 128 def test_fruity_send(): fruity_send = get_plugin("fruity-send.fst", FruitySend) assert fruity_send.dry == 256 assert fruity_send.send_to == -1 assert fruity_send.pan == 0 assert fruity_send.volume == 256 def test_fruity_soft_clipper(): fruity_soft_clipper = get_plugin("fruity-soft-clipper.fst", FruitySoftClipper) assert fruity_soft_clipper.threshold == 100 assert fruity_soft_clipper.post == 128 def test_fruity_stereo_enhancer(): fruity_stereo_enhancer = get_plugin("fruity-stereo-enhancer.fst", FruityStereoEnhancer) assert fruity_stereo_enhancer.stereo_separation == 0 assert fruity_stereo_enhancer.effect_position == "post" assert fruity_stereo_enhancer.phase_offset == 0 assert fruity_stereo_enhancer.phase_inversion == "none" assert fruity_stereo_enhancer.pan == 0 assert fruity_stereo_enhancer.volume == 256 def test_plucked(): plucked = get_plugin("plucked.fst", Plucked) assert plucked.decay == 176 assert plucked.color == 56 assert plucked.normalize assert plucked.gate assert not plucked.widen def test_soundgoodizer(): soundgoodizer = get_plugin("soundgoodizer.fst", Soundgoodizer) assert soundgoodizer.amount == 600 assert soundgoodizer.mode == "A" def test_vst_plugin(): djmfilter = get_plugin("xfer-djmfilter.fst", VSTPlugin) assert djmfilter.name == "DJMFilter" assert djmfilter.vendor == "Xfer Records" assert ( djmfilter.plugin_path == r"C:\Program Files\Common Files\VST2\Xfer Records\DJMFilter_x64.dll" ) def test_fruity_wrapper(): wrapper = get_plugin("fruity-wrapper.fst", VSTPlugin) # WrapperEvent properties assert not wrapper.compact assert not wrapper.demo_mode assert not wrapper.detached assert not wrapper.directx assert not wrapper.disabled assert wrapper.generator assert wrapper.height == 410 assert not wrapper.minimized assert wrapper.multithreaded assert wrapper.page == WrapperPage.Settings assert not wrapper.smart_disable assert wrapper.visible assert wrapper.width == 561 # VSTPluginEvent properties assert wrapper.automation.notify_changes assert wrapper.compatibility.buffers_maxsize assert wrapper.compatibility.fast_idle assert not wrapper.compatibility.fixed_buffers assert wrapper.compatibility.process_maximum assert wrapper.compatibility.reset_on_transport assert wrapper.compatibility.send_loop assert not wrapper.compatibility.use_time_offset assert wrapper.midi.input == 6 assert wrapper.midi.output == 9 assert wrapper.midi.pb_range == 36 assert not wrapper.midi.send_modx assert not wrapper.midi.send_pb assert wrapper.midi.send_release assert wrapper.processing.allow_sd assert not wrapper.processing.bridged assert wrapper.processing.keep_state assert wrapper.processing.multithreaded assert wrapper.processing.notify_render assert wrapper.ui.accept_drop assert not wrapper.ui.always_update assert wrapper.ui.dpi_aware assert not wrapper.ui.scale_editor ================================================ FILE: tests/test_project.py ================================================ from __future__ import annotations import datetime import pathlib import textwrap import pytest import pyflp from pyflp.project import VALID_PPQS, FileFormat, FLVersion, PanLaw, Project def test_project(project: Project): assert project.artists == "demberto" assert project.channel_count == 19 assert ( project.comments == textwrap.dedent( """\ This is a testing FLP used by PyFLP - An FL Studio project file parser. Notes for contributors: 1. Make a separate item for every testable property (and its inverse if its a bool). 2. Give item names related to the property they will be tested for. Terms: "item(s)": Refers to a channel, insert, slot, track, pattern, timemarker, etc. """ ).replace("\n", "\r") ) # Who the hell uses \r? assert project.created_on == datetime.datetime(2022, 9, 16, 20, 47, 12, 746000) assert project.data_path == pathlib.Path("") assert project.format == FileFormat.Project assert project.genre == "Testing..." assert project.licensed assert project.licensee == "VIKTORKHLEBNIKOV38394416" assert project.looped assert project.main_pitch == 0 assert project.main_volume is None assert project.pan_law == PanLaw.Circular assert project.ppq == 96 assert project.show_info assert project.tempo == 69.420 # ! assert project.time_spent == datetime.timedelta(hours=2, minutes=35, seconds=53) assert project.title == "PyFLP Test FLP" assert project.url == "https://github.com/demberto/PyFLP" assert project.version == FLVersion(20, 8, 4, 2576) with pytest.raises(ValueError, match="cannot be less than zero"): project.channel_count = -1 with pytest.raises(ValueError, match=f"{VALID_PPQS}"): project.ppq = 0 with pytest.raises(ValueError, match="10.0-522.0"): project.tempo = 999.0 with pytest.raises(ValueError, match="major.minor.build.patch?"): project.version = "2.2" # type: ignore def test_null_check(project: Project, tmp_path: pathlib.Path): pyflp.save(project, tmp_path / "null_check.flp") b1 = open(pathlib.Path(__file__).parent / "assets" / "FL 20.8.4.flp", "rb").read() b2 = open(tmp_path / "null_check.flp", "rb").read() # result = b1 == b2 # ! Don't compare 2 big bytes objects in pytest EVER assert b1 == b2 ================================================ FILE: tox.ini ================================================ [tox] envlist = precommit,py{38,39,310,311},pypy{38,39},docs minversion = 4.0 parallel = auto [testenv] deps = -rrequirements.txt coverage mypy pytest commands = coverage run -m pytest mypy pyflp [testenv:precommit] skip_install = True deps = pre-commit commands = pre-commit run --all-files [testenv:docs] # Exclude GH Actions Mac OS runners due to PyEnchant (needed by sphinxcontrib.spelling) # issue on Apple silicon, see https://github.com/pyenchant/pyenchant/issues/265 platform = ^((?!darwin).)*$ base_python = py310 deps = -rdocs/requirements.txt -rrequirements.txt commands = sphinx-build -b linkcheck docs docs/_build/linkcheck [gh] python = 3.8: py38 3.9: py39 3.10: py310, docs 3.11: py311, precommit pypy-3.8: pypy38 pypy-3.9: pypy39