├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── create-zip-file.yml │ └── get_name.py ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── MIDIAnimator ├── __init__.py ├── data_structures │ ├── __init__.py │ └── midi.py ├── libs │ ├── __init__.py │ └── mido │ │ ├── __about__.py │ │ ├── __init__.py │ │ ├── backends │ │ ├── __init__.py │ │ ├── _parser_queue.py │ │ ├── amidi.py │ │ ├── backend.py │ │ ├── portmidi.py │ │ ├── portmidi_init.py │ │ ├── pygame.py │ │ ├── rtmidi.py │ │ ├── rtmidi_python.py │ │ └── rtmidi_utils.py │ │ ├── frozen.py │ │ ├── messages │ │ ├── __init__.py │ │ ├── checks.py │ │ ├── decode.py │ │ ├── encode.py │ │ ├── messages.py │ │ ├── specs.py │ │ └── strings.py │ │ ├── midifiles │ │ ├── __init__.py │ │ ├── meta.py │ │ ├── midifiles.py │ │ ├── tracks.py │ │ └── units.py │ │ ├── parser.py │ │ ├── ports.py │ │ ├── py2.py │ │ ├── sockets.py │ │ ├── syx.py │ │ ├── tokenizer.py │ │ └── version.py ├── src │ ├── __init__.py │ ├── algorithms.py │ ├── animation.py │ └── instruments.py ├── ui │ ├── __init__.py │ ├── operators.py │ └── panels.py └── utils │ ├── __init__.py │ ├── blender.py │ ├── gmInstrumentMap.py │ └── logger.py ├── README.md ├── docs ├── Makefile ├── api │ └── .gitkeep ├── build_api_docs.py ├── conf.py ├── demo_scene │ ├── demo.mid │ └── demo_scene.blend ├── general │ ├── animation_types.md │ ├── breakdown.md │ ├── future_plans.md │ ├── getting_started.md │ └── installation.md ├── images │ ├── breakdown_figurea.png │ ├── breakdown_figureb.png │ ├── breakdown_figurec.png │ ├── cube_anim_note_anchor_pt.png │ ├── cube_anim_properties.png │ ├── cube_note_example.png │ ├── cube_sorting_sort_name.png │ ├── cube_sorting_syntax.png │ ├── inst_1.png │ ├── inst_2.png │ ├── inst_3.png │ └── inst_4.png ├── index.md ├── make.bat ├── requirements.txt └── tutorials │ ├── adv_tutorial.md │ └── tutorial.md ├── noteOffData.py ├── pyproject.toml ├── readthedocs.yaml └── rust-impl ├── blender-addon ├── __init__.py ├── src │ ├── __init__.py │ └── core.py └── ui │ └── __init__.py └── midianimator ├── .gitignore ├── README.md ├── custom.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── manifest.json └── robots.txt ├── src-tauri ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── node_registry │ ├── Cargo.toml │ └── src │ │ └── lib.rs ├── src │ ├── blender │ │ ├── mod.rs │ │ ├── python │ │ │ ├── blender_scene_builder.py │ │ │ ├── blender_scene_sender.py │ │ │ └── dfs_execute.py │ │ └── scene_data.rs │ ├── command │ │ ├── event.rs │ │ ├── javascript.rs │ │ └── mod.rs │ ├── configs │ │ ├── default_nodes.json │ │ ├── keybinds.json │ │ └── settings.json │ ├── graph │ │ ├── execute.rs │ │ ├── executors │ │ │ ├── animation.rs │ │ │ ├── midi.rs │ │ │ ├── mod.rs │ │ │ ├── scene.rs │ │ │ └── utils.rs │ │ └── mod.rs │ ├── ipc │ │ ├── ipc.md │ │ └── mod.rs │ ├── lib.rs │ ├── main.rs │ ├── midi │ │ └── mod.rs │ ├── scene_generics │ │ └── mod.rs │ ├── state │ │ ├── mod.rs │ │ └── state.md │ ├── ui │ │ ├── keybinds.rs │ │ ├── menu.rs │ │ └── mod.rs │ └── utils │ │ ├── gm_instrument_map.rs │ │ ├── mod.rs │ │ └── ui.rs ├── tauri.conf.json └── tests │ ├── ipc_test.rs │ ├── midi_test.rs │ ├── test_midi_type_0_rs_4_14_24.mid │ └── test_midi_type_1_rs_4_14_24.mid ├── src ├── App.tsx ├── blender.png ├── collapse-left.png ├── collapse-right.png ├── components │ ├── ConnectionLine.tsx │ ├── IPCLink.tsx │ ├── MacTrafficLights.tsx │ ├── MenuBar.tsx │ ├── NodeGraph.tsx │ ├── Panel.tsx │ ├── PanelContent.tsx │ ├── StatusBar.tsx │ ├── Tab.tsx │ ├── Tool.tsx │ └── ToolBar.tsx ├── contexts │ └── StateContext.tsx ├── index.css ├── logo.png ├── main.tsx ├── nodes │ ├── BaseNode.tsx │ ├── NodeHeader.tsx │ ├── NodeTypes.tsx │ ├── animation_generator.tsx │ ├── get_midi_file.tsx │ ├── get_midi_track_data.tsx │ ├── keyframes_from_object.tsx │ ├── nodes_and_ui.md │ ├── scene_link.tsx │ └── viewer.tsx ├── reportWebVitals.tsx ├── setupTests.tsx ├── styles.tsx ├── utils │ └── node.tsx └── windows │ └── Settings.tsx ├── tailwind.config.js ├── tsconfig.json └── vite.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: jamesa08 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve MIDIAnimator. 4 | title: '' 5 | labels: bug 6 | assignees: jamesa08 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | 13 | 14 | **Steps to Reproduce** 15 | 16 | 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | 27 | 28 | **Additional context** 29 | 30 | 31 | 32 | **Logs** 33 | 34 | To get the log: 35 | - Produce the bug in Blender. 36 | - Find the `scene.copy_log()` operator by searching for the parameter in Blender by pressing `Space` in the 3D Viewport 37 | - Paste the log below: 38 | 39 | ``` 40 | log goes here... 41 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: jamesa08 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem you're facing? Please describe. If not, mark No.** 11 | *If this is related to a feature already built-in to the add-on, please put it here.* 12 | 13 | 14 | 15 | 16 | **Describe your feature/proposed changes** 17 | 18 | 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | -------------------------------------------------------------------------------- /.github/workflows/create-zip-file.yml: -------------------------------------------------------------------------------- 1 | name: Create MIDIAnimator build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | zipFile: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check Out branch 10 | uses: actions/checkout@v3 11 | 12 | # thank you to https://brandur.org/fragments/github-actions-env-vars-in-env-vars for the tip on using enviroment files! 13 | - name: Set enviroment variables 14 | run: | 15 | cd ${{ github.workspace }} 16 | NAME=$(python3 ${{ github.workspace }}/.github/workflows/get_name.py --readFile ${{ github.workspace }}/MIDIAnimator/__init__.py) 17 | HASH=$(git log -1 --format='%h') 18 | echo "NAME=$NAME" >> $GITHUB_ENV 19 | echo "HASH=$HASH" >> $GITHUB_ENV 20 | echo "ZIPNAME=$NAME-$HASH" >> $GITHUB_ENV 21 | 22 | - name: Zip up MIDIAnimator 23 | run: | 24 | cd ${{ github.workspace }} 25 | mkdir artifacts/ 26 | mkdir artifacts/${{ env.ZIPNAME }} 27 | echo 'creating ${{ env.ZIPNAME }} in ${{ github.workspace }}/artifacts/' 28 | cp -a MIDIAnimator/ ${{ github.workspace }}/artifacts/${{ env.ZIPNAME }} 29 | 30 | - name: Download all workflow run artifacts 31 | uses: actions/upload-artifact@v3 32 | with: 33 | name: ${{ env.ZIPNAME }} 34 | path: ${{ github.workspace }}/artifacts/${{ env.ZIPNAME }} -------------------------------------------------------------------------------- /.github/workflows/get_name.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import argparse 3 | 4 | parser = argparse.ArgumentParser(description="pull bl_info out of init file and concatenate a name") 5 | parser.add_argument("--readFile") 6 | 7 | args = parser.parse_args(sys.argv[1:]) 8 | 9 | # this is so hacky just to pull bl_info out, but it works! 10 | with open(args.readFile) as file: 11 | lines = file.readlines() 12 | 13 | state = False 14 | bl_info_lines = [] 15 | 16 | for line in lines: 17 | line = line.strip() 18 | if "bl_info" in line: 19 | state = True 20 | 21 | if state: 22 | bl_info_lines.append(line) 23 | 24 | if line == "}": 25 | break 26 | 27 | bl_info = {} 28 | exec("\n".join(bl_info_lines)) 29 | 30 | names_split = bl_info['name'].split(" ") 31 | names_split.insert(2, "-".join([str(val) for val in bl_info['version']])) 32 | out = "-".join(names_split) 33 | 34 | print(out) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VSCode 2 | .vscode 3 | .DS_Store 4 | 5 | # PyCharm 6 | .idea 7 | 8 | # Byte-compiled / optimized / DLL files 9 | __pycache__/ 10 | *.py[cod] 11 | *$py.class 12 | 13 | # C extensions 14 | *.so 15 | 16 | # Distribution / packaging 17 | .Python 18 | build/ 19 | develop-eggs/ 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | wheels/ 30 | pip-wheel-metadata/ 31 | share/python-wheels/ 32 | *.egg-info/ 33 | .installed.cfg 34 | *.egg 35 | MANIFEST 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .nox/ 51 | .coverage 52 | .coverage.* 53 | .cache 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | *.py,cover 58 | .hypothesis/ 59 | .pytest_cache/ 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # Sphinx documentation 139 | /docs/build/ 140 | /docs/api/* 141 | !/docs/api/.gitkeep 142 | -------------------------------------------------------------------------------- /MIDIAnimator/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | from __future__ import annotations 15 | 16 | bl_info = { 17 | "name": "MIDIAnimator beta4.1", 18 | "description": "A cohesive, open-source solution to animating Blender objects using a MIDI file.", 19 | "author": "James Alt (et al.)", 20 | "version": (0, 4, 1), 21 | "blender": (3, 0, 0), 22 | "location": "Scripting Space", 23 | "doc_url": "https://midianimatordocs.readthedocs.io/en/latest/", 24 | "tracker_url": "https://github.com/jamesa08/MIDIAnimator/issues", 25 | "warning": "MIDIAnimator is currently in beta. If you encounter any issues, please feel free to open an issue on GitHub (https://github.com/jamesa08/MIDIAnimator/issues)", 26 | "support": "COMMUNITY", 27 | "category": "Animation" 28 | } 29 | 30 | if "bpy" in locals(): 31 | # Running under Blender 32 | import importlib 33 | importlib.reload(src) 34 | importlib.reload(utils) 35 | importlib.reload(ui) 36 | else: 37 | # Running under external instance 38 | import bpy 39 | from . src import * 40 | from . src.instruments import Instruments, MIDIAnimatorObjectProperties, MIDIAnimatorCollectionProperties, MIDIAnimatorSceneProperties 41 | from . utils import * 42 | from . utils.logger import logger 43 | from . ui import * 44 | from . ui.operators import SCENE_OT_quick_add_props, SCENE_OT_copy_log 45 | from . ui.panels import VIEW3D_PT_edit_instrument_information, VIEW3D_PT_edit_object_information, VIEW3D_PT_add_notes_quick 46 | 47 | 48 | 49 | classes = (SCENE_OT_quick_add_props, SCENE_OT_copy_log, VIEW3D_PT_edit_instrument_information, VIEW3D_PT_edit_object_information, VIEW3D_PT_add_notes_quick, MIDIAnimatorObjectProperties, MIDIAnimatorCollectionProperties, MIDIAnimatorSceneProperties) 50 | 51 | def register(): 52 | for bpyClass in classes: 53 | bpy.utils.register_class(bpyClass) 54 | 55 | 56 | bpy.types.Object.midi = bpy.props.PointerProperty(type=MIDIAnimatorObjectProperties) 57 | bpy.types.Collection.midi = bpy.props.PointerProperty(type=MIDIAnimatorCollectionProperties) 58 | bpy.types.Scene.midi = bpy.props.PointerProperty(type=MIDIAnimatorSceneProperties) 59 | 60 | for item in Instruments: 61 | # register all Instrument properties 62 | item.value.cls.properties() 63 | 64 | logger.info("MIDIAnimator registered successfully") 65 | logger.info(f"MIDIAnimator version {'.'.join([str(i) for i in bl_info['version']])} {bl_info['name'].replace('MIDIAnimator ', '')}.") 66 | 67 | def unregister(): 68 | for bpyClass in classes: 69 | bpy.utils.unregister_class(bpyClass) 70 | 71 | del bpy.types.Object.midi 72 | del bpy.types.Collection.midi 73 | del bpy.types.Scene.midi 74 | 75 | logger.info("MIDIAnimator unregistered successfully") -------------------------------------------------------------------------------- /MIDIAnimator/libs/__init__.py: -------------------------------------------------------------------------------- 1 | # credits: 2 | # local copy of mido library used here 3 | # https://github.com/mido/mido 4 | 5 | if "bpy" in locals(): 6 | import importlib 7 | importlib.reload(mido) 8 | else: 9 | from . import mido -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.2.10' 2 | __author__ = 'Ole Martin Bjorndalen' 3 | __author_email__ = 'ombdalen@gmail.com' 4 | __url__ = 'https://mido.readthedocs.io/' 5 | __license__ = 'MIT' 6 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | MIDI Objects for Python 4 | 5 | Mido is a library for working with MIDI messages and ports. It's 6 | designed to be as straight forward and Pythonic as possible. 7 | 8 | Creating messages: 9 | 10 | Message(type, **parameters) -- create a new message 11 | MetaMessage(type, **parameters) -- create a new meta message 12 | UnknownMetaMessage(type_byte, data=None, time=0) 13 | 14 | Ports: 15 | 16 | open_input(name=None, virtual=False, callback=None) -- open an input port 17 | open_output(name=None, virtual=False, -- open an output port 18 | autoreset=False) 19 | open_ioport(name=None, virtual=False, -- open an I/O port (capable 20 | callback=None, autoreset=False) of both input and output) 21 | 22 | get_input_names() -- return a list of names of available input ports 23 | get_output_names() -- return a list of names of available output ports 24 | get_ioport_names() -- return a list of names of available I/O ports 25 | 26 | MIDI files: 27 | 28 | MidiFile(filename, **kwargs) -- open a MIDI file 29 | MidiTrack() -- a MIDI track 30 | bpm2tempo() -- convert beats per minute to MIDI file tempo 31 | tempo2bpm() -- convert MIDI file tempo to beats per minute 32 | merge_tracks(tracks) -- merge tracks into one track 33 | 34 | SYX files: 35 | 36 | read_syx_file(filename) -- read a SYX file 37 | write_syx_file(filename, messages, 38 | plaintext=False) -- write a SYX file 39 | Parsing MIDI streams: 40 | 41 | parse(bytes) -- parse a single message bytes 42 | (any iterable that generates integers in 0..127) 43 | parse_all(bytes) -- parse all messages bytes 44 | Parser -- MIDI parser class 45 | 46 | Parsing objects serialized with str(message): 47 | 48 | parse_string(string) -- parse a string containing a message 49 | parse_string_stream(iterable) -- parse strings from an iterable and 50 | generate messages 51 | 52 | Sub modules: 53 | 54 | ports -- useful tools for working with ports 55 | 56 | For more on MIDI, see: 57 | 58 | http://www.midi.org/ 59 | 60 | 61 | Getting started: 62 | 63 | >>> import mido 64 | >>> m = mido.Message('note_on', note=60, velocity=64) 65 | >>> m 66 | 67 | >>> m.type 68 | 'note_on' 69 | >>> m.channel = 6 70 | >>> m.note = 19 71 | >>> m.copy(velocity=120) 72 | 73 | >>> s = mido.Message('sysex', data=[byte for byte in range(5)]) 74 | >>> s.data 75 | (0, 1, 2, 3, 4) 76 | >>> s.hex() 77 | 'F0 00 01 02 03 04 F7' 78 | >>> len(s) 79 | 7 80 | 81 | >>> default_input = mido.open_input() 82 | >>> default_input.name 83 | 'MPK mini MIDI 1' 84 | >>> output = mido.open_output('SD-20 Part A') 85 | >>> 86 | >>> for message in default_input: 87 | ... output.send(message) 88 | 89 | >>> get_input_names() 90 | ['MPK mini MIDI 1', 'SH-201'] 91 | """ 92 | from __future__ import absolute_import 93 | import os 94 | from .backends.backend import Backend 95 | from . import ports, sockets 96 | from .messages import (Message, parse_string, parse_string_stream, 97 | format_as_string, MIN_PITCHWHEEL, MAX_PITCHWHEEL, 98 | MIN_SONGPOS, MAX_SONGPOS) 99 | from .parser import Parser, parse, parse_all 100 | from .midifiles import (MidiFile, MidiTrack, merge_tracks, 101 | MetaMessage, UnknownMetaMessage, 102 | bpm2tempo, tempo2bpm, tick2second, second2tick, 103 | KeySignatureError) 104 | from .syx import read_syx_file, write_syx_file 105 | from .version import version_info 106 | from .__about__ import (__version__, __author__, __author_email__, 107 | __url__, __license__) 108 | 109 | # Prevent splat import. 110 | __all__ = [] 111 | 112 | 113 | def set_backend(name=None, load=False): 114 | """Set current backend. 115 | 116 | name can be a module name like 'mido.backends.rtmidi' or 117 | a Backend object. 118 | 119 | If no name is passed, the default backend will be used. 120 | 121 | This will replace all the open_*() and get_*_name() functions 122 | in top level mido module. The module will be loaded the first 123 | time one of those functions is called.""" 124 | 125 | glob = globals() 126 | 127 | if isinstance(name, Backend): 128 | backend = name 129 | else: 130 | backend = Backend(name, load=load, use_environ=True) 131 | glob['backend'] = backend 132 | 133 | for name in dir(backend): 134 | if name.split('_')[0] in ['open', 'get']: 135 | glob[name] = getattr(backend, name) 136 | 137 | 138 | set_backend() 139 | 140 | del os, absolute_import 141 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/MIDIAnimator/libs/mido/backends/__init__.py -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/_parser_queue.py: -------------------------------------------------------------------------------- 1 | import time 2 | from .. import ports 3 | from ..parser import Parser 4 | from ..py2 import PY2 5 | 6 | from threading import RLock 7 | 8 | if PY2: 9 | import Queue as queue 10 | else: 11 | import queue 12 | 13 | 14 | class ParserQueue: 15 | """ 16 | Thread safe message queue with built in MIDI parser. 17 | 18 | This should be avaiable to other backend implementations and perhaps 19 | also in the public API, but the API needs a bit of review. (Ideally This 20 | would replace the parser.) 21 | 22 | q = ParserQueue() 23 | 24 | q.put(msg) 25 | q.put_bytes([0xf8, 0, 0]) 26 | 27 | msg = q.get() 28 | msg = q.poll() 29 | """ 30 | def __init__(self): 31 | self._queue = queue.Queue() 32 | self._parser = Parser() 33 | self._parser_lock = RLock() 34 | 35 | def put(self, msg): 36 | self._queue.put(msg) 37 | 38 | def put_bytes(self, msg_bytes): 39 | with self._parser_lock: 40 | self._parser.feed(msg_bytes) 41 | for msg in self._parser: 42 | self.put(msg) 43 | 44 | def _get_py2(self): 45 | # In Python 2 queue.get() doesn't respond to CTRL-C. A workaroud is 46 | # to call queue.get(timeout=100) (very high timeout) in a loop, but all 47 | # that does is poll with a timeout of 50 milliseconds. This results in 48 | # much too high latency. 49 | # 50 | # It's better to do our own polling with a shorter sleep time. 51 | # 52 | # See Issue #49 and https://bugs.python.org/issue8844 53 | sleep_time = ports.get_sleep_time() 54 | while True: 55 | try: 56 | return self._queue.get_nowait() 57 | except queue.Empty: 58 | time.sleep(sleep_time) 59 | continue 60 | 61 | # TODO: add timeout? 62 | def get(self): 63 | if PY2: 64 | return self._get_py2() 65 | else: 66 | return self._queue.get() 67 | 68 | def poll(self): 69 | try: 70 | return self._queue.get_nowait() 71 | except queue.Empty: 72 | return None 73 | 74 | def __iter__(self): 75 | while True: 76 | return self.get() 77 | 78 | def iterpoll(self): 79 | while True: 80 | msg = self.poll() 81 | if msg is None: 82 | return 83 | else: 84 | yield msg 85 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/amidi.py: -------------------------------------------------------------------------------- 1 | """Mido amidi backend 2 | 3 | Very experimental backend using amidi to access the ALSA rawmidi 4 | interface. 5 | 6 | TODO: 7 | 8 | * use parser instead of from_hex()? 9 | * default port name 10 | * do sysex messages work? 11 | * starting amidi for every message sent is costly 12 | """ 13 | import os 14 | import select 15 | import threading 16 | import subprocess 17 | from ..messages import Message 18 | from ._common import PortMethods, InputMethods, OutputMethods 19 | """ 20 | Dir Device Name 21 | IO hw:1,0,0 UM-1 MIDI 1 22 | IO hw:2,0,0 nanoKONTROL2 MIDI 1 23 | IO hw:2,0,0 MPK mini MIDI 1 24 | """ 25 | 26 | 27 | def get_devices(): 28 | devices = [] 29 | 30 | lines = os.popen('amidi -l').read().splitlines() 31 | for line in lines[1:]: 32 | mode, device, name = line.strip().split(None, 2) 33 | 34 | devices.append({'name': name.strip(), 35 | 'device': device, 36 | 'is_input': 'I' in mode, 37 | 'is_output': 'O' in mode, 38 | }) 39 | 40 | return devices 41 | 42 | 43 | def _get_device(name, mode): 44 | for dev in get_devices(): 45 | if name == dev['name'] and dev[mode]: 46 | return dev 47 | else: 48 | raise IOError('unknown port {!r}'.format(name)) 49 | 50 | 51 | class Input(PortMethods, InputMethods): 52 | def __init__(self, name=None, **kwargs): 53 | self.name = name 54 | self.closed = False 55 | 56 | self._proc = None 57 | self._poller = select.poll() 58 | self._lock = threading.RLock() 59 | 60 | dev = _get_device(self.name, 'is_input') 61 | self._proc = subprocess.Popen(['amidi', '-d', 62 | '-p', dev['device']], 63 | stdout=subprocess.PIPE) 64 | 65 | self._poller.register(self._proc.stdout, select.POLLIN) 66 | 67 | def _read_message(self): 68 | line = self._proc.stdout.readline().strip().decode('ascii') 69 | if line: 70 | return Message.from_hex(line) 71 | else: 72 | # The first line is sometimes blank. 73 | return None 74 | 75 | def receive(self, block=True): 76 | if not block: 77 | return self.poll() 78 | 79 | while True: 80 | msg = self.poll() 81 | if msg: 82 | return msg 83 | 84 | # Wait for message. 85 | self._poller.poll() 86 | 87 | def poll(self): 88 | with self._lock: 89 | while self._poller.poll(0): 90 | msg = self._read_message() 91 | if msg is not None: 92 | return msg 93 | 94 | def close(self): 95 | if not self.closed: 96 | if self._proc: 97 | self._proc.kill() 98 | self._proc = None 99 | self.closed = True 100 | 101 | 102 | class Output(PortMethods, OutputMethods): 103 | def __init__(self, name=None, autoreset=False, **kwargs): 104 | self.name = name 105 | self.autoreset = autoreset 106 | self.closed = False 107 | 108 | self._dev = _get_device(self.name, 'is_output') 109 | 110 | def send(self, msg): 111 | proc = subprocess.Popen(['amidi', '--send-hex', msg.hex(), 112 | '-p', self._dev['device']]) 113 | proc.wait() 114 | 115 | def close(self): 116 | if not self.closed: 117 | if self.autoreset: 118 | self.reset() 119 | 120 | self.closed = True 121 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/portmidi_init.py: -------------------------------------------------------------------------------- 1 | """ 2 | Low-level wrapper for PortMidi library 3 | 4 | Copied straight from Grant Yoshida's portmidizero, with slight 5 | modifications. 6 | """ 7 | import sys 8 | from ctypes import (CDLL, CFUNCTYPE, POINTER, Structure, c_char_p, 9 | c_int, c_long, c_uint, c_void_p, cast, 10 | create_string_buffer) 11 | import ctypes.util 12 | 13 | dll_name = '' 14 | if sys.platform == 'darwin': 15 | dll_name = ctypes.util.find_library('libportmidi.dylib') 16 | elif sys.platform in ('win32', 'cygwin'): 17 | dll_name = 'portmidi.dll' 18 | else: 19 | dll_name = 'libportmidi.so' 20 | 21 | lib = CDLL(dll_name) 22 | 23 | null = None 24 | false = 0 25 | true = 1 26 | 27 | # portmidi.h 28 | 29 | # From portmidi.h 30 | PM_HOST_ERROR_MSG_LEN = 256 31 | 32 | 33 | def get_host_error_message(): 34 | """Return host error message.""" 35 | buf = create_string_buffer(PM_HOST_ERROR_MSG_LEN) 36 | lib.Pm_GetHostErrorText(buf, PM_HOST_ERROR_MSG_LEN) 37 | return buf.raw.decode().rstrip('\0') 38 | 39 | 40 | PmError = c_int 41 | # PmError enum 42 | pmNoError = 0 43 | pmHostError = -10000 44 | pmInvalidDeviceId = -9999 45 | pmInsufficientMemory = -9989 46 | pmBufferTooSmall = -9979 47 | pmBufferOverflow = -9969 48 | pmBadPtr = -9959 49 | pmBadData = -9994 50 | pmInternalError = -9993 51 | pmBufferMaxSize = -9992 52 | 53 | lib.Pm_Initialize.restype = PmError 54 | lib.Pm_Terminate.restype = PmError 55 | 56 | PmDeviceID = c_int 57 | 58 | PortMidiStreamPtr = c_void_p 59 | PmStreamPtr = PortMidiStreamPtr 60 | PortMidiStreamPtrPtr = POINTER(PortMidiStreamPtr) 61 | 62 | lib.Pm_HasHostError.restype = c_int 63 | lib.Pm_HasHostError.argtypes = [PortMidiStreamPtr] 64 | 65 | lib.Pm_GetErrorText.restype = c_char_p 66 | lib.Pm_GetErrorText.argtypes = [PmError] 67 | 68 | lib.Pm_GetHostErrorText.argtypes = [c_char_p, c_uint] 69 | 70 | pmNoDevice = -1 71 | 72 | 73 | class PmDeviceInfo(Structure): 74 | _fields_ = [("structVersion", c_int), 75 | ("interface", c_char_p), 76 | ("name", c_char_p), 77 | ("is_input", c_int), 78 | ("is_output", c_int), 79 | ("opened", c_int)] 80 | 81 | 82 | PmDeviceInfoPtr = POINTER(PmDeviceInfo) 83 | 84 | lib.Pm_CountDevices.restype = c_int 85 | lib.Pm_GetDefaultOutputDeviceID.restype = PmDeviceID 86 | lib.Pm_GetDefaultInputDeviceID.restype = PmDeviceID 87 | 88 | PmTimestamp = c_long 89 | PmTimeProcPtr = CFUNCTYPE(PmTimestamp, c_void_p) 90 | NullTimeProcPtr = cast(null, PmTimeProcPtr) 91 | 92 | # PmBefore is not defined 93 | 94 | lib.Pm_GetDeviceInfo.argtypes = [PmDeviceID] 95 | lib.Pm_GetDeviceInfo.restype = PmDeviceInfoPtr 96 | 97 | lib.Pm_OpenInput.restype = PmError 98 | lib.Pm_OpenInput.argtypes = [PortMidiStreamPtrPtr, 99 | PmDeviceID, 100 | c_void_p, 101 | c_long, 102 | PmTimeProcPtr, 103 | c_void_p] 104 | 105 | lib.Pm_OpenOutput.restype = PmError 106 | lib.Pm_OpenOutput.argtypes = [PortMidiStreamPtrPtr, 107 | PmDeviceID, 108 | c_void_p, 109 | c_long, 110 | PmTimeProcPtr, 111 | c_void_p, 112 | c_long] 113 | 114 | lib.Pm_SetFilter.restype = PmError 115 | lib.Pm_SetFilter.argtypes = [PortMidiStreamPtr, c_long] 116 | 117 | lib.Pm_SetChannelMask.restype = PmError 118 | lib.Pm_SetChannelMask.argtypes = [PortMidiStreamPtr, c_int] 119 | 120 | lib.Pm_Abort.restype = PmError 121 | lib.Pm_Abort.argtypes = [PortMidiStreamPtr] 122 | 123 | lib.Pm_Close.restype = PmError 124 | lib.Pm_Close.argtypes = [PortMidiStreamPtr] 125 | 126 | PmMessage = c_long 127 | 128 | 129 | class PmEvent(Structure): 130 | _fields_ = [("message", PmMessage), 131 | ("timestamp", PmTimestamp)] 132 | 133 | 134 | PmEventPtr = POINTER(PmEvent) 135 | 136 | lib.Pm_Read.restype = PmError 137 | lib.Pm_Read.argtypes = [PortMidiStreamPtr, PmEventPtr, c_long] 138 | 139 | lib.Pm_Poll.restype = PmError 140 | lib.Pm_Poll.argtypes = [PortMidiStreamPtr] 141 | 142 | lib.Pm_Write.restype = PmError 143 | lib.Pm_Write.argtypes = [PortMidiStreamPtr, PmEventPtr, c_long] 144 | 145 | lib.Pm_WriteShort.restype = PmError 146 | lib.Pm_WriteShort.argtypes = [PortMidiStreamPtr, PmTimestamp, c_long] 147 | 148 | lib.Pm_WriteSysEx.restype = PmError 149 | lib.Pm_WriteSysEx.argtypes = [PortMidiStreamPtr, PmTimestamp, c_char_p] 150 | 151 | # porttime.h 152 | 153 | # PtError enum 154 | PtError = c_int 155 | ptNoError = 0 156 | ptHostError = -10000 157 | ptAlreadyStarted = -9999 158 | ptAlreadyStopped = -9998 159 | ptInsufficientMemory = -9997 160 | 161 | PtTimestamp = c_long 162 | PtCallback = CFUNCTYPE(PmTimestamp, c_void_p) 163 | 164 | lib.Pt_Start.restype = PtError 165 | lib.Pt_Start.argtypes = [c_int, PtCallback, c_void_p] 166 | 167 | lib.Pt_Stop.restype = PtError 168 | lib.Pt_Started.restype = c_int 169 | lib.Pt_Time.restype = PtTimestamp 170 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/pygame.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mido ports for pygame.midi. 3 | 4 | Pygame uses PortMidi, so this is perhaps not very useful. 5 | 6 | http://www.pygame.org/docs/ref/midi.html 7 | """ 8 | 9 | from __future__ import absolute_import 10 | from pygame import midi 11 | from ..ports import BaseInput, BaseOutput 12 | 13 | 14 | def _get_device(device_id): 15 | keys = ['interface', 'name', 'is_input', 'is_output', 'opened'] 16 | info = dict(zip(keys, midi.get_device_info(device_id))) 17 | # TODO: correct encoding? 18 | info['name'] = info['name'].decode('utf-8') 19 | info['id'] = device_id 20 | return info 21 | 22 | 23 | def _get_default_device(get_input): 24 | if get_input: 25 | device_id = midi.get_default_input_id() 26 | else: 27 | device_id = midi.get_default_output_id() 28 | 29 | if device_id < 0: 30 | raise IOError('no default port found') 31 | 32 | return _get_device(device_id) 33 | 34 | 35 | def _get_named_device(name, get_input): 36 | # Look for the device by name and type (input / output) 37 | for device in get_devices(): 38 | if device['name'] != name: 39 | continue 40 | 41 | # Skip if device is the wrong type 42 | if get_input: 43 | if device['is_output']: 44 | continue 45 | else: 46 | if device['is_input']: 47 | continue 48 | 49 | if device['opened']: 50 | raise IOError('port already opened: {!r}'.format(name)) 51 | 52 | return device 53 | else: 54 | raise IOError('unknown port: {!r}'.format(name)) 55 | 56 | 57 | def get_devices(**kwargs): 58 | midi.init() 59 | return [_get_device(device_id) for device_id in range(midi.get_count())] 60 | 61 | 62 | class PortCommon(object): 63 | """ 64 | Mixin with common things for input and output ports. 65 | """ 66 | def _open(self, **kwargs): 67 | if kwargs.get('virtual'): 68 | raise ValueError('virtual ports are not supported' 69 | ' by the Pygame backend') 70 | elif kwargs.get('callback'): 71 | raise ValueError('callbacks are not supported' 72 | ' by the Pygame backend') 73 | 74 | midi.init() 75 | 76 | if self.name is None: 77 | device = _get_default_device(self.is_input) 78 | self.name = device['name'] 79 | else: 80 | device = _get_named_device(self.name, self.is_input) 81 | 82 | if device['opened']: 83 | if self.is_input: 84 | devtype = 'input' 85 | else: 86 | devtype = 'output' 87 | raise IOError('{} port {!r} is already open'.format(devtype, 88 | self.name)) 89 | if self.is_input: 90 | self._port = midi.Input(device['id']) 91 | else: 92 | self._port = midi.Output(device['id']) 93 | 94 | self._device_type = 'Pygame/{}'.format(device['interface']) 95 | 96 | def _close(self): 97 | self._port.close() 98 | 99 | 100 | class Input(PortCommon, BaseInput): 101 | """ 102 | PortMidi Input port 103 | """ 104 | def _receive(self, block=True): 105 | # I get hanging notes if MAX_EVENTS > 1, so I'll have to 106 | # resort to calling Pm_Read() in a loop until there are no 107 | # more pending events. 108 | 109 | while self._port.poll(): 110 | bytes, time = self._port.read(1)[0] 111 | self._parser.feed(bytes) 112 | 113 | 114 | class Output(PortCommon, BaseOutput): 115 | """ 116 | PortMidi output port 117 | """ 118 | def _send(self, message): 119 | if message.type == 'sysex': 120 | # Python 2 version of Pygame accepts a bytes or list here 121 | # while Python 3 version requires bytes. 122 | # According to the docs it should accept both so this may be 123 | # a bug in Pygame: 124 | # https://www.pygame.org/docs/ref/midi.html#pygame.midi.Output.write_sys_ex 125 | self._port.write_sys_ex(midi.time(), bytes(message.bin())) 126 | else: 127 | self._port.write_short(*message.bytes()) 128 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/rtmidi_python.py: -------------------------------------------------------------------------------- 1 | """Backend for rtmidi-python: 2 | 3 | https://pypi.python.org/pypi/rtmidi-python 4 | 5 | To use this backend copy (or link) it to somewhere in your Python path 6 | and call: 7 | 8 | mido.set_backend('mido.backends.rtmidi_python') 9 | 10 | or set shell variable $MIDO_BACKEND to mido.backends.rtmidi_python 11 | 12 | TODO: 13 | 14 | * add support for APIs. 15 | 16 | * active_sensing is still filtered. (The same is true for 17 | mido.backends.rtmidi.)There may be a way to remove this filtering. 18 | 19 | """ 20 | from __future__ import absolute_import 21 | import rtmidi_python as rtmidi 22 | # TODO: change this to a relative import if the backend is included in 23 | # the package. 24 | from ..ports import BaseInput, BaseOutput 25 | from ..py2 import PY2 26 | 27 | if PY2: 28 | import Queue as queue 29 | else: 30 | import queue 31 | 32 | 33 | def get_devices(api=None, **kwargs): 34 | devices = {} 35 | 36 | input_names = rtmidi.MidiIn().ports 37 | output_names = rtmidi.MidiOut().ports 38 | 39 | for name in input_names + output_names: 40 | if name not in devices: 41 | devices[name] = { 42 | 'name': name, 43 | 'is_input': name in input_names, 44 | 'is_output': name in output_names, 45 | } 46 | 47 | return list(devices.values()) 48 | 49 | 50 | class PortCommon(object): 51 | def _open(self, virtual=False, **kwargs): 52 | 53 | self._queue = queue.Queue() 54 | self._callback = None 55 | 56 | # rtapi = _get_api_id(api) 57 | opening_input = hasattr(self, 'receive') 58 | 59 | if opening_input: 60 | self._rt = rtmidi.MidiIn() 61 | self._rt.ignore_types(False, False, True) 62 | self.callback = kwargs.get('callback') 63 | else: 64 | self._rt = rtmidi.MidiOut() # rtapi=rtapi) 65 | # Turn of ignore of sysex, time and active_sensing. 66 | 67 | ports = self._rt.ports 68 | 69 | if virtual: 70 | if self.name is None: 71 | raise IOError('virtual port must have a name') 72 | self._rt.open_virtual_port(self.name) 73 | else: 74 | if self.name is None: 75 | # TODO: this could fail if list is empty. 76 | # In RtMidi, the default port is the first port. 77 | try: 78 | self.name = ports[0] 79 | except IndexError: 80 | raise IOError('no ports available') 81 | 82 | try: 83 | port_id = ports.index(self.name) 84 | except ValueError: 85 | raise IOError('unknown port {!r}'.format(self.name)) 86 | 87 | try: 88 | self._rt.open_port(port_id) 89 | except RuntimeError as err: 90 | raise IOError(*err.args) 91 | 92 | # api = _api_to_name[self._rt.get_current_api()] 93 | api = '' 94 | self._device_type = 'RtMidi/{}'.format(api) 95 | if virtual: 96 | self._device_type = 'virtual {}'.format(self._device_type) 97 | 98 | @property 99 | def callback(self): 100 | return self._callback 101 | 102 | @callback.setter 103 | def callback(self, func): 104 | self._callback = func 105 | if func is None: 106 | self._rt.callback = None 107 | else: 108 | self._rt.callback = self._callback_wrapper 109 | 110 | def _callback_wrapper(self, msg_bytes, timestamp): 111 | self._parser.feed(msg_bytes) 112 | for message in self._parser: 113 | if self.callback: 114 | self.callback(message) 115 | 116 | def _close(self): 117 | self._rt.close_port() 118 | del self._rt # Virtual ports are closed when this is deleted. 119 | 120 | 121 | class Input(PortCommon, BaseInput): 122 | def _receive(self, block=True): 123 | # Since there is no blocking read in RtMidi, the block 124 | # flag is ignored and the enclosing receive() takes care 125 | # of blocking. 126 | 127 | while True: 128 | message_data, timestamp = self._rt.get_message() 129 | if message_data is None: 130 | break 131 | else: 132 | self._parser.feed(message_data) 133 | 134 | 135 | class Output(PortCommon, BaseOutput): 136 | def _send(self, message): 137 | self._rt.send_message(message.bytes()) 138 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/backends/rtmidi_utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for RtMidi backend. 2 | 3 | These are in a separate file so they can be tested without 4 | the `python-rtmidi` package. 5 | 6 | """ 7 | 8 | 9 | def expand_alsa_port_name(port_names, name): 10 | """Expand ALSA port name. 11 | 12 | RtMidi/ALSA includes client name and client:port number in 13 | the port name, for example: 14 | 15 | TiMidity:TiMidity port 0 128:0 16 | 17 | This allows you to specify only port name or client:port name when 18 | opening a port. It will compare the name to each name in 19 | port_names (typically returned from get_*_names()) and try these 20 | three variants in turn: 21 | 22 | TiMidity:TiMidity port 0 128:0 23 | TiMidity:TiMidity port 0 24 | TiMidity port 0 25 | 26 | It returns the first match. If no match is found it returns the 27 | passed name so the caller can deal with it. 28 | """ 29 | if name is None: 30 | return None 31 | 32 | for port_name in port_names: 33 | if name == port_name: 34 | return name 35 | 36 | # Try without client and port number (for example 128:0). 37 | without_numbers = port_name.rsplit(None, 1)[0] 38 | if name == without_numbers: 39 | return port_name 40 | 41 | if ':' in without_numbers: 42 | without_client = without_numbers.split(':', 1)[1] 43 | if name == without_client: 44 | return port_name 45 | else: 46 | # Let caller deal with it. 47 | return name 48 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/frozen.py: -------------------------------------------------------------------------------- 1 | from .messages import Message 2 | from .midifiles import MetaMessage, UnknownMetaMessage 3 | 4 | 5 | class Frozen(object): 6 | def __setattr__(self, *_): 7 | raise ValueError('frozen message is immutable') 8 | 9 | def __hash__(self): 10 | return hash(tuple(sorted(vars(self).items()))) 11 | 12 | 13 | class FrozenMessage(Frozen, Message): 14 | pass 15 | 16 | 17 | class FrozenMetaMessage(Frozen, MetaMessage): 18 | pass 19 | 20 | 21 | class FrozenUnknownMetaMessage(Frozen, UnknownMetaMessage): 22 | def __repr__(self): 23 | return 'Frozen' + UnknownMetaMessage.__repr__(self) 24 | 25 | 26 | def is_frozen(msg): 27 | """Return True if message is frozen, otherwise False.""" 28 | return isinstance(msg, Frozen) 29 | 30 | 31 | # TODO: these two functions are almost the same except inverted. There 32 | # should be a way to refactor them to lessen code duplication. 33 | 34 | def freeze_message(msg): 35 | """Freeze message. 36 | 37 | Returns a frozen version of the message. Frozen messages are 38 | immutable, hashable and can be used as dictionary keys. 39 | 40 | Will return None if called with None. This allows you to do things 41 | like:: 42 | 43 | msg = freeze_message(port.poll()) 44 | """ 45 | if isinstance(msg, Frozen): 46 | # Already frozen. 47 | return msg 48 | elif isinstance(msg, Message): 49 | class_ = FrozenMessage 50 | elif isinstance(msg, UnknownMetaMessage): 51 | class_ = FrozenUnknownMetaMessage 52 | elif isinstance(msg, MetaMessage): 53 | class_ = FrozenMetaMessage 54 | elif msg is None: 55 | return None 56 | else: 57 | raise ValueError('first argument must be a message or None') 58 | 59 | frozen = class_.__new__(class_) 60 | vars(frozen).update(vars(msg)) 61 | return frozen 62 | 63 | 64 | def thaw_message(msg): 65 | """Thaw message. 66 | 67 | Returns a mutable version of a frozen message. 68 | 69 | Will return None if called with None. 70 | """ 71 | if not isinstance(msg, Frozen): 72 | # Already thawed, just return a copy. 73 | return msg.copy() 74 | elif isinstance(msg, FrozenMessage): 75 | class_ = Message 76 | elif isinstance(msg, FrozenUnknownMetaMessage): 77 | class_ = UnknownMetaMessage 78 | elif isinstance(msg, FrozenMetaMessage): 79 | class_ = MetaMessage 80 | elif msg is None: 81 | return None 82 | else: 83 | raise ValueError('first argument must be a message or None') 84 | 85 | thawed = class_.__new__(class_) 86 | vars(thawed).update(vars(msg)) 87 | return thawed 88 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/__init__.py: -------------------------------------------------------------------------------- 1 | from .checks import check_time 2 | from .specs import (SPEC_LOOKUP, SPEC_BY_TYPE, SPEC_BY_STATUS, 3 | MIN_PITCHWHEEL, MAX_PITCHWHEEL, MIN_SONGPOS, MAX_SONGPOS) 4 | from .messages import (BaseMessage, Message, parse_string, 5 | format_as_string, parse_string_stream) 6 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/checks.py: -------------------------------------------------------------------------------- 1 | from numbers import Integral, Real 2 | from .specs import (SPEC_BY_TYPE, MIN_SONGPOS, MAX_SONGPOS, 3 | MIN_PITCHWHEEL, MAX_PITCHWHEEL) 4 | from ..py2 import convert_py2_bytes 5 | 6 | 7 | def check_type(type_): 8 | if type_ not in SPEC_BY_TYPE: 9 | raise ValueError('invalid message type {!r}'.format(type_)) 10 | 11 | 12 | def check_channel(channel): 13 | if not isinstance(channel, Integral): 14 | raise TypeError('channel must be int') 15 | elif not 0 <= channel <= 15: 16 | raise ValueError('channel must be in range 0..15') 17 | 18 | 19 | def check_pos(pos): 20 | if not isinstance(pos, Integral): 21 | raise TypeError('song pos must be int') 22 | elif not MIN_SONGPOS <= pos <= MAX_SONGPOS: 23 | raise ValueError('song pos must be in range {}..{}'.format( 24 | MIN_SONGPOS, MAX_SONGPOS)) 25 | 26 | 27 | def check_pitch(pitch): 28 | if not isinstance(pitch, Integral): 29 | raise TypeError('pichwheel value must be int') 30 | elif not MIN_PITCHWHEEL <= pitch <= MAX_PITCHWHEEL: 31 | raise ValueError('pitchwheel value must be in range {}..{}'.format( 32 | MIN_PITCHWHEEL, MAX_PITCHWHEEL)) 33 | 34 | 35 | def check_data(data_bytes): 36 | for byte in convert_py2_bytes(data_bytes): 37 | check_data_byte(byte) 38 | 39 | 40 | def check_frame_type(value): 41 | if not isinstance(value, Integral): 42 | raise TypeError('frame_type must be int') 43 | elif not 0 <= value <= 7: 44 | raise ValueError('frame_type must be in range 0..7') 45 | 46 | 47 | def check_frame_value(value): 48 | if not isinstance(value, Integral): 49 | raise TypeError('frame_value must be int') 50 | elif not 0 <= value <= 15: 51 | raise ValueError('frame_value must be in range 0..15') 52 | 53 | 54 | def check_data_byte(value): 55 | if not isinstance(value, Integral): 56 | raise TypeError('data byte must be int') 57 | elif not 0 <= value <= 127: 58 | raise ValueError('data byte must be in range 0..127') 59 | 60 | 61 | def check_time(time): 62 | if not isinstance(time, Real): 63 | raise TypeError('time must be int or float') 64 | 65 | 66 | _CHECKS = { 67 | 'type': check_type, 68 | 'data': check_data, 69 | 'channel': check_channel, 70 | 'control': check_data_byte, 71 | 'data': check_data, 72 | 'frame_type': check_frame_type, 73 | 'frame_value': check_frame_value, 74 | 'note': check_data_byte, 75 | 'pitch': check_pitch, 76 | 'pos': check_pos, 77 | 'program': check_data_byte, 78 | 'song': check_data_byte, 79 | 'value': check_data_byte, 80 | 'velocity': check_data_byte, 81 | 'time': check_time, 82 | } 83 | 84 | 85 | def check_value(name, value): 86 | _CHECKS[name](value) 87 | 88 | 89 | def check_msgdict(msgdict): 90 | spec = SPEC_BY_TYPE.get(msgdict['type']) 91 | if spec is None: 92 | raise ValueError('unknown message type {!r}'.format(msgdict['type'])) 93 | 94 | for name, value in msgdict.items(): 95 | if name not in spec['attribute_names']: 96 | raise ValueError( 97 | '{} message has no attribute {}'.format(spec['type'], name)) 98 | 99 | check_value(name, value) 100 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/decode.py: -------------------------------------------------------------------------------- 1 | from .specs import (SYSEX_START, SYSEX_END, 2 | SPEC_BY_STATUS, CHANNEL_MESSAGES, 3 | MIN_PITCHWHEEL) 4 | from .checks import check_data 5 | from ..py2 import convert_py2_bytes 6 | 7 | 8 | def _decode_sysex_data(data): 9 | return {'data': tuple(data)} 10 | 11 | 12 | def _decode_quarter_frame_data(data): 13 | return {'frame_type': data[0] >> 4, 14 | 'frame_value': data[0] & 15} 15 | 16 | 17 | def _decode_songpos_data(data): 18 | return {'pos': data[0] | (data[1] << 7)} 19 | 20 | 21 | def _decode_pitchwheel_data(data): 22 | return {'pitch': data[0] | ((data[1] << 7) + MIN_PITCHWHEEL)} 23 | 24 | 25 | def _make_special_cases(): 26 | cases = { 27 | 0xe0: _decode_pitchwheel_data, 28 | 0xf0: _decode_sysex_data, 29 | 0xf1: _decode_quarter_frame_data, 30 | 0xf2: _decode_songpos_data, 31 | } 32 | 33 | for i in range(16): 34 | cases[0xe0 | i] = _decode_pitchwheel_data 35 | 36 | return cases 37 | 38 | 39 | _SPECIAL_CASES = _make_special_cases() 40 | 41 | 42 | def _decode_data_bytes(status_byte, data, spec): 43 | # Subtract 1 for status byte. 44 | if len(data) != (spec['length'] - 1): 45 | raise ValueError( 46 | 'wrong number of bytes for {} message'.format(spec['type'])) 47 | 48 | # TODO: better name than args? 49 | names = [name for name in spec['value_names'] if name != 'channel'] 50 | args = {name: value for name, value in zip(names, data)} 51 | 52 | if status_byte in CHANNEL_MESSAGES: 53 | # Channel is stored in the lower nibble of the status byte. 54 | args['channel'] = status_byte & 0x0f 55 | 56 | return args 57 | 58 | 59 | def decode_message(msg_bytes, time=0, check=True): 60 | """Decode message bytes and return messages as a dictionary. 61 | 62 | Raises ValueError if the bytes are out of range or the message is 63 | invalid. 64 | 65 | This is not a part of the public API. 66 | """ 67 | # TODO: this function is getting long. 68 | msg_bytes = convert_py2_bytes(msg_bytes) 69 | 70 | if len(msg_bytes) == 0: 71 | raise ValueError('message is 0 bytes long') 72 | 73 | status_byte = msg_bytes[0] 74 | data = msg_bytes[1:] 75 | 76 | try: 77 | spec = SPEC_BY_STATUS[status_byte] 78 | except KeyError: 79 | raise ValueError('invalid status byte {!r}'.format(status_byte)) 80 | 81 | msg = { 82 | 'type': spec['type'], 83 | 'time': time, 84 | } 85 | 86 | # Sysex. 87 | if status_byte == SYSEX_START: 88 | if len(data) < 1: 89 | raise ValueError('sysex without end byte') 90 | 91 | end = data[-1] 92 | data = data[:-1] 93 | if end != SYSEX_END: 94 | raise ValueError('invalid sysex end byte {!r}'.format(end)) 95 | 96 | if check: 97 | check_data(data) 98 | 99 | if status_byte in _SPECIAL_CASES: 100 | if status_byte in CHANNEL_MESSAGES: 101 | msg['channel'] = status_byte & 0x0f 102 | 103 | msg.update(_SPECIAL_CASES[status_byte](data)) 104 | else: 105 | msg.update(_decode_data_bytes(status_byte, data, spec)) 106 | 107 | return msg 108 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/encode.py: -------------------------------------------------------------------------------- 1 | from .specs import CHANNEL_MESSAGES, SPEC_BY_TYPE, MIN_PITCHWHEEL 2 | 3 | 4 | def _encode_pitchwheel(msg): 5 | pitch = msg['pitch'] - MIN_PITCHWHEEL 6 | return [0xe0 | msg['channel'], pitch & 0x7f, pitch >> 7] 7 | 8 | 9 | def _encode_sysex(msg): 10 | return [0xf0] + list(msg['data']) + [0xf7] 11 | 12 | 13 | def _encode_quarter_frame(msg): 14 | return [0xf1, msg['frame_type'] << 4 | msg['frame_value']] 15 | 16 | 17 | def _encode_songpos(data): 18 | pos = data['pos'] 19 | return [0xf2, pos & 0x7f, pos >> 7] 20 | 21 | 22 | def _encode_note_off(msg): 23 | return [0x80 | msg['channel'], msg['note'], msg['velocity']] 24 | 25 | 26 | def _encode_note_on(msg): 27 | return [0x90 | msg['channel'], msg['note'], msg['velocity']] 28 | 29 | 30 | def _encode_control_change(msg): 31 | return [0xb0 | msg['channel'], msg['control'], msg['value']] 32 | 33 | 34 | _SPECIAL_CASES = { 35 | 'pitchwheel': _encode_pitchwheel, 36 | 'sysex': _encode_sysex, 37 | 'quarter_frame': _encode_quarter_frame, 38 | 'songpos': _encode_songpos, 39 | 40 | # These are so common that they get special cases to speed things up. 41 | 'note_off': _encode_note_off, 42 | 'note_on': _encode_note_on, 43 | 'control_change': _encode_control_change, 44 | } 45 | 46 | 47 | def encode_message(msg): 48 | """Encode msg dict as a list of bytes. 49 | 50 | TODO: Add type and value checking. 51 | (Can be turned off with keyword argument.) 52 | 53 | This is not a part of the public API. 54 | """ 55 | 56 | encode = _SPECIAL_CASES.get(msg['type']) 57 | if encode: 58 | return encode(msg) 59 | else: 60 | spec = SPEC_BY_TYPE[msg['type']] 61 | status_byte = spec['status_byte'] 62 | 63 | if status_byte in CHANNEL_MESSAGES: 64 | status_byte |= msg['channel'] 65 | 66 | data = [msg[name] for name in spec['value_names'] if name != 'channel'] 67 | 68 | return [status_byte] + data 69 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/specs.py: -------------------------------------------------------------------------------- 1 | """Definitions and lookup tables for MIDI messages. 2 | 3 | TODO: 4 | 5 | * add lookup functions for messages definitions by type and status 6 | byte. 7 | """ 8 | # TODO: these include undefined messages. 9 | CHANNEL_MESSAGES = set(range(0x80, 0xf0)) 10 | COMMON_MESSAGES = set(range(0xf0, 0xf8)) 11 | REALTIME_MESSAGES = set(range(0xf8, 0x100)) 12 | 13 | SYSEX_START = 0xf0 14 | SYSEX_END = 0xf7 15 | 16 | # Pitchwheel is a 14 bit signed integer 17 | MIN_PITCHWHEEL = -8192 18 | MAX_PITCHWHEEL = 8191 19 | 20 | # Song pos is a 14 bit unsigned integer 21 | MIN_SONGPOS = 0 22 | MAX_SONGPOS = 16383 23 | 24 | 25 | def _defmsg(status_byte, type_, value_names, length): 26 | return { 27 | 'status_byte': status_byte, 28 | 'type': type_, 29 | 'value_names': value_names, 30 | 'attribute_names': set(value_names) | {'type', 'time'}, 31 | 'length': length, 32 | } 33 | 34 | 35 | SPECS = [ 36 | _defmsg(0x80, 'note_off', ('channel', 'note', 'velocity'), 3), 37 | _defmsg(0x90, 'note_on', ('channel', 'note', 'velocity'), 3), 38 | _defmsg(0xa0, 'polytouch', ('channel', 'note', 'value'), 3), 39 | _defmsg(0xb0, 'control_change', ('channel', 'control', 'value'), 3), 40 | _defmsg(0xc0, 'program_change', ('channel', 'program',), 2), 41 | _defmsg(0xd0, 'aftertouch', ('channel', 'value',), 2), 42 | _defmsg(0xe0, 'pitchwheel', ('channel', 'pitch',), 3), 43 | 44 | # System common messages. 45 | # 0xf4 and 0xf5 are undefined. 46 | _defmsg(0xf0, 'sysex', ('data',), float('inf')), 47 | _defmsg(0xf1, 'quarter_frame', ('frame_type', 'frame_value'), 2), 48 | _defmsg(0xf2, 'songpos', ('pos',), 3), 49 | _defmsg(0xf3, 'song_select', ('song',), 2), 50 | _defmsg(0xf6, 'tune_request', (), 1), 51 | 52 | # System real time messages. 53 | # 0xf9 and 0xfd are undefined. 54 | _defmsg(0xf8, 'clock', (), 1), 55 | _defmsg(0xfa, 'start', (), 1), 56 | _defmsg(0xfb, 'continue', (), 1), 57 | _defmsg(0xfc, 'stop', (), 1), 58 | _defmsg(0xfe, 'active_sensing', (), 1), 59 | _defmsg(0xff, 'reset', (), 1), 60 | ] 61 | 62 | 63 | def _make_spec_lookups(specs): 64 | lookup = {} 65 | by_status = {} 66 | by_type = {} 67 | 68 | for spec in specs: 69 | type_ = spec['type'] 70 | status_byte = spec['status_byte'] 71 | 72 | by_type[type_] = spec 73 | 74 | if status_byte in CHANNEL_MESSAGES: 75 | for channel in range(16): 76 | by_status[status_byte | channel] = spec 77 | else: 78 | by_status[status_byte] = spec 79 | 80 | lookup.update(by_status) 81 | lookup.update(by_type) 82 | 83 | return lookup, by_status, by_type 84 | 85 | 86 | SPEC_LOOKUP, SPEC_BY_STATUS, SPEC_BY_TYPE = _make_spec_lookups(SPECS) 87 | 88 | REALTIME_TYPES = {'tune_request', 'clock', 'start', 'continue', 'stop'} 89 | 90 | DEFAULT_VALUES = { 91 | 'channel': 0, 92 | 'control': 0, 93 | 'data': (), 94 | 'frame_type': 0, 95 | 'frame_value': 0, 96 | 'note': 0, 97 | 'pitch': 0, 98 | 'pos': 0, 99 | 'program': 0, 100 | 'song': 0, 101 | 'value': 0, 102 | 'velocity': 64, 103 | 104 | 'time': 0, 105 | } 106 | 107 | 108 | # TODO: should this be in decode.py? 109 | 110 | def make_msgdict(type_, overrides): 111 | """Return a new message. 112 | 113 | Returns a dictionary representing a message. 114 | 115 | Message values can be overriden. 116 | 117 | No type or value checking is done. The caller is responsible for 118 | calling check_msgdict(). 119 | """ 120 | if type_ in SPEC_BY_TYPE: 121 | spec = SPEC_BY_TYPE[type_] 122 | else: 123 | raise LookupError('Unknown message type {!r}'.format(type_)) 124 | 125 | msg = {'type': type_, 'time': DEFAULT_VALUES['time']} 126 | 127 | for name in spec['value_names']: 128 | msg[name] = DEFAULT_VALUES[name] 129 | 130 | msg.update(overrides) 131 | 132 | return msg 133 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/messages/strings.py: -------------------------------------------------------------------------------- 1 | from .specs import SPEC_BY_TYPE, make_msgdict 2 | 3 | 4 | def msg2str(msg, include_time=True): 5 | type_ = msg['type'] 6 | spec = SPEC_BY_TYPE[type_] 7 | 8 | words = [type_] 9 | 10 | for name in spec['value_names']: 11 | value = msg[name] 12 | 13 | if name == 'data': 14 | value = '({})'.format(','.join(str(byte) for byte in value)) 15 | words.append('{}={}'.format(name, value)) 16 | 17 | if include_time: 18 | words.append('time={}'.format(msg['time'])) 19 | 20 | return str.join(' ', words) 21 | 22 | 23 | def _parse_time(value): 24 | # Convert to int if possible. 25 | try: 26 | return int(value) 27 | except ValueError: 28 | pass 29 | 30 | try: 31 | return float(value) 32 | except ValueError: 33 | pass 34 | 35 | raise ValueError('invalid time {!r}'.format(value)) 36 | 37 | 38 | def _parse_data(value): 39 | if not value.startswith('(') and value.endswith(')'): 40 | raise ValueError('missing parentheses in data message') 41 | 42 | try: 43 | return [int(byte) for byte in value[1:-1].split(',')] 44 | except ValueError: 45 | raise ValueError('unable to parse data bytes') 46 | 47 | 48 | def str2msg(text): 49 | """Parse str format and return message dict. 50 | 51 | No type or value checking is done. The caller is responsible for 52 | calling check_msgdict(). 53 | """ 54 | words = text.split() 55 | type_ = words[0] 56 | args = words[1:] 57 | 58 | msg = {} 59 | 60 | for arg in args: 61 | name, value = arg.split('=', 1) 62 | if name == 'time': 63 | value = _parse_time(value) 64 | elif name == 'data': 65 | value = _parse_data(value) 66 | else: 67 | value = int(value) 68 | 69 | msg[name] = value 70 | 71 | return make_msgdict(type_, msg) 72 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/midifiles/__init__.py: -------------------------------------------------------------------------------- 1 | from .meta import MetaMessage, UnknownMetaMessage, KeySignatureError 2 | from .units import tick2second, second2tick, bpm2tempo, tempo2bpm 3 | from .tracks import MidiTrack, merge_tracks 4 | from .midifiles import MidiFile 5 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/midifiles/tracks.py: -------------------------------------------------------------------------------- 1 | from .meta import MetaMessage 2 | 3 | 4 | class MidiTrack(list): 5 | @property 6 | def name(self): 7 | """Name of the track. 8 | 9 | This will return the name from the first track_name meta 10 | message in the track, or '' if there is no such message. 11 | 12 | Setting this property will update the name field of the first 13 | track_name message in the track. If no such message is found, 14 | one will be added to the beginning of the track with a delta 15 | time of 0.""" 16 | for message in self: 17 | if message.type == 'track_name': 18 | return message.name 19 | else: 20 | return '' 21 | 22 | @name.setter 23 | def name(self, name): 24 | # Find the first track_name message and modify it. 25 | for message in self: 26 | if message.type == 'track_name': 27 | message.name = name 28 | return 29 | else: 30 | # No track name found, add one. 31 | self.insert(0, MetaMessage('track_name', name=name, time=0)) 32 | 33 | def copy(self): 34 | return self.__class__(self) 35 | 36 | def __getitem__(self, index_or_slice): 37 | # Retrieve item from the MidiTrack 38 | lst = list.__getitem__(self, index_or_slice) 39 | if isinstance(index_or_slice, int): 40 | # If an index was provided, return the list element 41 | return lst 42 | else: 43 | # Otherwise, construct a MidiTrack to return. 44 | # TODO: this make a copy of the list. Is there a better way? 45 | return self.__class__(lst) 46 | 47 | def __add__(self, other): 48 | return self.__class__(list.__add__(self, other)) 49 | 50 | def __mul__(self, other): 51 | return self.__class__(list.__mul__(self, other)) 52 | 53 | def __repr__(self): 54 | if len(self) == 0: 55 | messages = '' 56 | elif len(self) == 1: 57 | messages = '[{}]'.format(self[0]) 58 | else: 59 | messages = '[\n {}]'.format(',\n '.join(repr(m) for m in self)) 60 | return '{}({})'.format(self.__class__.__name__, messages) 61 | 62 | 63 | def _to_abstime(messages): 64 | """Convert messages to absolute time.""" 65 | now = 0 66 | for msg in messages: 67 | now += msg.time 68 | yield msg.copy(time=now) 69 | 70 | 71 | def _to_reltime(messages): 72 | """Convert messages to relative time.""" 73 | now = 0 74 | for msg in messages: 75 | delta = msg.time - now 76 | yield msg.copy(time=delta) 77 | now = msg.time 78 | 79 | 80 | def fix_end_of_track(messages): 81 | """Remove all end_of_track messages and add one at the end. 82 | 83 | This is used by merge_tracks() and MidiFile.save().""" 84 | # Accumulated delta time from removed end of track messages. 85 | # This is added to the next message. 86 | accum = 0 87 | 88 | for msg in messages: 89 | if msg.type == 'end_of_track': 90 | accum += msg.time 91 | else: 92 | if accum: 93 | delta = accum + msg.time 94 | yield msg.copy(time=delta) 95 | accum = 0 96 | else: 97 | yield msg 98 | 99 | yield MetaMessage('end_of_track', time=accum) 100 | 101 | 102 | def merge_tracks(tracks): 103 | """Returns a MidiTrack object with all messages from all tracks. 104 | 105 | The messages are returned in playback order with delta times 106 | as if they were all in one track. 107 | """ 108 | messages = [] 109 | for track in tracks: 110 | messages.extend(_to_abstime(track)) 111 | 112 | messages.sort(key=lambda msg: msg.time) 113 | 114 | return MidiTrack(fix_end_of_track(_to_reltime(messages))) 115 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/midifiles/units.py: -------------------------------------------------------------------------------- 1 | def tick2second(tick, ticks_per_beat, tempo): 2 | """Convert absolute time in ticks to seconds. 3 | 4 | Returns absolute time in seconds for a chosen MIDI file time 5 | resolution (ticks per beat, also called PPQN or pulses per quarter 6 | note) and tempo (microseconds per beat). 7 | """ 8 | scale = tempo * 1e-6 / ticks_per_beat 9 | return tick * scale 10 | 11 | 12 | def second2tick(second, ticks_per_beat, tempo): 13 | """Convert absolute time in seconds to ticks. 14 | 15 | Returns absolute time in ticks for a chosen MIDI file time 16 | resolution (ticks per beat, also called PPQN or pulses per quarter 17 | note) and tempo (microseconds per beat). 18 | """ 19 | scale = tempo * 1e-6 / ticks_per_beat 20 | return second / scale 21 | 22 | 23 | def bpm2tempo(bpm): 24 | """Convert beats per minute to MIDI file tempo. 25 | 26 | Returns microseconds per beat as an integer:: 27 | 28 | 240 => 250000 29 | 120 => 500000 30 | 60 => 1000000 31 | """ 32 | # One minute is 60 million microseconds. 33 | return int(round((60 * 1000000) / bpm)) 34 | 35 | 36 | def tempo2bpm(tempo): 37 | """Convert MIDI file tempo to BPM. 38 | 39 | Returns BPM as an integer or float:: 40 | 41 | 250000 => 240 42 | 500000 => 120 43 | 1000000 => 60 44 | """ 45 | # One minute is 60 million microseconds. 46 | return (60 * 1000000) / tempo 47 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | MIDI Parser 3 | 4 | There is no need to use this module directly. All you need is 5 | available in the top level module. 6 | """ 7 | from collections import deque 8 | from .messages import Message 9 | from .tokenizer import Tokenizer 10 | 11 | 12 | class Parser(object): 13 | """ 14 | MIDI byte stream parser 15 | 16 | Parses a stream of MIDI bytes and produces messages. 17 | 18 | Data can be put into the parser in the form of 19 | integers, byte arrays or byte strings. 20 | """ 21 | def __init__(self, data=None): 22 | # For historical reasons self.messages is public and must be a 23 | # deque(). (It is referenced directly inside ports.) 24 | self.messages = deque() 25 | self._tok = Tokenizer() 26 | if data: 27 | self.feed(data) 28 | 29 | def _decode(self): 30 | for midi_bytes in self._tok: 31 | self.messages.append(Message.from_bytes(midi_bytes)) 32 | 33 | def feed(self, data): 34 | """Feed MIDI data to the parser. 35 | 36 | Accepts any object that produces a sequence of integers in 37 | range 0..255, such as: 38 | 39 | [0, 1, 2] 40 | (0, 1, 2) 41 | [for i in range(256)] 42 | (for i in range(256)] 43 | bytearray() 44 | b'' # Will be converted to integers in Python 2. 45 | """ 46 | self._tok.feed(data) 47 | self._decode() 48 | 49 | def feed_byte(self, byte): 50 | """Feed one MIDI byte into the parser. 51 | 52 | The byte must be an integer in range 0..255. 53 | """ 54 | self._tok.feed_byte(byte) 55 | self._decode() 56 | 57 | def get_message(self): 58 | """Get the first parsed message. 59 | 60 | Returns None if there is no message yet. If you don't want to 61 | deal with None, you can use pending() to see how many messages 62 | you can get before you get None, or just iterate over the 63 | parser. 64 | """ 65 | for msg in self: 66 | return msg 67 | else: 68 | return None 69 | 70 | def pending(self): 71 | """Return the number of pending messages.""" 72 | return len(self.messages) 73 | 74 | __len__ = pending 75 | 76 | def __iter__(self): 77 | """Yield messages that have been parsed so far.""" 78 | while len(self.messages) > 0: 79 | yield self.messages.popleft() 80 | 81 | 82 | def parse_all(data): 83 | """Parse MIDI data and return a list of all messages found. 84 | 85 | This is typically used to parse a little bit of data with a few 86 | messages in it. It's best to use a Parser object for larger 87 | amounts of data. Also, tt's often easier to use parse() if you 88 | know there is only one message in the data. 89 | """ 90 | return list(Parser(data)) 91 | 92 | 93 | def parse(data): 94 | """ Parse MIDI data and return the first message found. 95 | 96 | Data after the first message is ignored. Use parse_all() 97 | to parse more than one message. 98 | """ 99 | return Parser(data).get_message() 100 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/py2.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY2 = (sys.version_info.major == 2) 4 | 5 | 6 | def convert_py2_bytes(data): 7 | """Convert bytes object to bytearray in Python 2. 8 | 9 | Many parts of Mido such as ``Parser.feed()`` and 10 | ``Message.from_bytes()`` accept an iterable of integers. 11 | 12 | In Python 3 you can pass a byte string:: 13 | 14 | >>> list(b'\x01\x02\x03') 15 | [1, 2, 3] 16 | 17 | while in Python 2 this happens:: 18 | 19 | >>> list(b'\x01\x02\x03') 20 | ['\x01', '\x02', '\x03'] 21 | 22 | This function patches over the difference:: 23 | 24 | >>> list(convert_py2_bytes(b'\x01\x02\x03')) 25 | [1, 2, 3] 26 | 27 | """ 28 | if PY2 and isinstance(data, bytes): 29 | return bytearray(data) 30 | else: 31 | return data 32 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/syx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read and write SYX file format 3 | """ 4 | from __future__ import print_function 5 | import re 6 | from .parser import Parser 7 | 8 | 9 | def read_syx_file(filename): 10 | """Read sysex messages from SYX file. 11 | 12 | Returns a list of sysex messages. 13 | 14 | This handles both the text (hexadecimal) and binary 15 | formats. Messages other than sysex will be ignored. Raises 16 | ValueError if file is plain text and byte is not a 2-digit hex 17 | number. 18 | """ 19 | with open(filename, 'rb') as infile: 20 | data = infile.read() 21 | 22 | if len(data) == 0: 23 | # Empty file. 24 | return [] 25 | 26 | parser = Parser() 27 | 28 | # data[0] will give a byte string in Python 2 and an integer in 29 | # Python 3. 30 | if data[0] in (b'\xf0', 240): 31 | # Binary format. 32 | parser.feed(data) 33 | else: 34 | text = data.decode('latin1') 35 | data = bytearray.fromhex(re.sub(r'\s', ' ', text)) 36 | parser.feed(data) 37 | 38 | return [msg for msg in parser if msg.type == 'sysex'] 39 | 40 | 41 | def write_syx_file(filename, messages, plaintext=False): 42 | """Write sysex messages to a SYX file. 43 | 44 | Messages other than sysex will be skipped. 45 | 46 | By default this will write the binary format. Pass 47 | ``plaintext=True`` to write the plain text format (hex encoded 48 | ASCII text). 49 | """ 50 | messages = [m for m in messages if m.type == 'sysex'] 51 | 52 | if plaintext: 53 | with open(filename, 'wt') as outfile: 54 | for message in messages: 55 | outfile.write(message.hex()) 56 | outfile.write('\n') 57 | else: 58 | with open(filename, 'wb') as outfile: 59 | for message in messages: 60 | outfile.write(message.bin()) 61 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/tokenizer.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from numbers import Integral 3 | from .messages.specs import SYSEX_START, SYSEX_END, SPEC_BY_STATUS 4 | from .py2 import convert_py2_bytes 5 | 6 | 7 | class Tokenizer(object): 8 | """ 9 | Splits a MIDI byte stream into messages. 10 | """ 11 | def __init__(self, data=None): 12 | """Create a new decoder.""" 13 | 14 | self._status = 0 15 | self._bytes = [] 16 | self._messages = deque() 17 | self._datalen = 0 18 | 19 | if data is not None: 20 | self.feed(data) 21 | 22 | def _feed_status_byte(self, status): 23 | if status == SYSEX_END: 24 | if self._status == SYSEX_START: 25 | self._bytes.append(SYSEX_END) 26 | self._messages.append(self._bytes) 27 | 28 | self._status = 0 29 | 30 | elif 0xf8 <= status <= 0xff: 31 | if self._status != SYSEX_START: 32 | # Realtime messages are only allowed inside sysex 33 | # messages. Reset parser. 34 | self._status = 0 35 | 36 | if status in SPEC_BY_STATUS: 37 | self._messages.append([status]) 38 | 39 | elif status in SPEC_BY_STATUS: 40 | # New message. 41 | spec = SPEC_BY_STATUS[status] 42 | 43 | if spec['length'] == 1: 44 | self._messages.append([status]) 45 | self._status = 0 46 | else: 47 | self._status = status 48 | self._bytes = [status] 49 | self._len = spec['length'] 50 | else: 51 | # Undefined message. Reset parser. 52 | # (Undefined realtime messages are handled above.) 53 | # self._status = 0 54 | pass 55 | 56 | def _feed_data_byte(self, byte): 57 | if self._status: 58 | self._bytes.append(byte) 59 | if len(self._bytes) == self._len: 60 | # Complete message. 61 | self._messages.append(self._bytes) 62 | self._status = 0 63 | else: 64 | # Ignore stray data byte. 65 | pass 66 | 67 | def feed_byte(self, byte): 68 | """Feed MIDI byte to the decoder. 69 | 70 | Takes an int in range [0..255]. 71 | """ 72 | if not isinstance(byte, Integral): 73 | raise TypeError('message byte must be integer') 74 | 75 | if 0 <= byte <= 255: 76 | if byte <= 127: 77 | return self._feed_data_byte(byte) 78 | else: 79 | return self._feed_status_byte(byte) 80 | else: 81 | raise ValueError('invalid byte value {!r}'.format(byte)) 82 | 83 | def feed(self, data): 84 | """Feed MIDI bytes to the decoder. 85 | 86 | Takes an iterable of ints in in range [0..255]. 87 | """ 88 | for byte in convert_py2_bytes(data): 89 | self.feed_byte(byte) 90 | 91 | def __len__(self): 92 | return len(self._messages) 93 | 94 | def __iter__(self): 95 | """Yield messages that have been parsed so far.""" 96 | while len(self._messages): 97 | yield self._messages.popleft() 98 | -------------------------------------------------------------------------------- /MIDIAnimator/libs/mido/version.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from .__about__ import __version__ 3 | 4 | VersionInfo = namedtuple('VersionInfo', 5 | ['major', 'minor', 'micro', 'releaselevel', 'serial']) 6 | 7 | 8 | def _make_version_info(version): 9 | if '-' in version: 10 | version, releaselevel = version.split('-') 11 | else: 12 | releaselevel = '' 13 | 14 | major, minor, micro = map(int, version.split('.')) 15 | 16 | return VersionInfo(major, minor, micro, releaselevel, 0) 17 | 18 | 19 | version_info = _make_version_info(__version__) 20 | -------------------------------------------------------------------------------- /MIDIAnimator/src/__init__.py: -------------------------------------------------------------------------------- 1 | if "bpy" in locals(): 2 | import importlib 3 | importlib.reload(midi) 4 | importlib.reload(animation) 5 | else: 6 | from ..data_structures import midi 7 | from . import animation 8 | 9 | import bpy -------------------------------------------------------------------------------- /MIDIAnimator/src/animation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import List, Dict 3 | import bpy 4 | 5 | from .. data_structures.midi import MIDITrack 6 | from ..utils.logger import logger, buffer 7 | from .. src.instruments import * 8 | from . algorithms import * 9 | 10 | class MIDIAnimatorNode: 11 | """This class encompasses all `Instrument` classes (and its subclasses).""" 12 | _instruments: List[Instrument] 13 | 14 | def __init__(self): 15 | self._instruments = [] 16 | 17 | def addInstrument(self, instrumentType: str=None, midiTrack: MIDITrack=None, objectCollection: bpy.types.Collection=None, custom=None, customVars: Dict=None): 18 | """adds an instrument to MIDIAnimator. This will create the class for you 19 | 20 | :param MIDITrack midiTrack: The `MIDITrack` object to create the instrument from 21 | :param bpy.types.Collection objectCollection: The collection (`bpy.types.Collection`) of Blender objects to be animated. 22 | :param class(Instrument) custom: a custom Instrument class that inherits from Instrument, defaults to None 23 | :param Dict customVars: a dictionary of custom vars you would like to send to your custom class, defaults to None 24 | :raises ValueError: if instrumentType="custom" and the customClass is None. 25 | """ 26 | 27 | assert type(midiTrack).__name__ == "MIDITrack", "Please pass in a type MIDITrack object." 28 | assert isinstance(objectCollection, bpy.types.Collection), "Please pass in a type collection for the objects to be animated." 29 | 30 | if instrumentType: 31 | logger.warn("The `instrumentType` parameter has been depercated and is no longer required.") 32 | objectCollection.midi.instrument_type = instrumentType 33 | 34 | instrumentType = objectCollection.midi.instrument_type 35 | 36 | try: 37 | if instrumentType == "custom": 38 | if custom is None: raise ValueError("Please pass a custom class object. Refer to the docs for help.") 39 | if customVars is not None: 40 | cls = custom(midiTrack, objectCollection, **customVars) 41 | else: 42 | cls = custom(midiTrack, objectCollection) 43 | else: 44 | for item in Instruments: 45 | value = item.value 46 | if value.identifier == instrumentType: 47 | instrumentCls = value.cls 48 | break 49 | 50 | cls = instrumentCls(midiTrack, objectCollection) 51 | except Exception as e: 52 | logger.exception(f"Error while creating instrument: '{e}'") 53 | raise e 54 | 55 | 56 | self._instruments.append(cls) 57 | 58 | def animate(self) -> None: 59 | """animate all of the tracks""" 60 | try: 61 | for instrument in self._instruments: 62 | instrument.animate() 63 | except Exception as e: 64 | logger.exception(f"Error while animating instrument: {e}") 65 | raise e -------------------------------------------------------------------------------- /MIDIAnimator/ui/__init__.py: -------------------------------------------------------------------------------- 1 | if "bpy" in locals(): 2 | import importlib 3 | importlib.reload(operators) 4 | importlib.reload(panels) 5 | else: 6 | from . import operators 7 | from . import panels 8 | -------------------------------------------------------------------------------- /MIDIAnimator/ui/panels.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from .. src.instruments import * 3 | import bpy 4 | 5 | class MIDIAniamtorPanel: 6 | bl_space_type = "VIEW_3D" 7 | bl_region_type = "UI" 8 | bl_category = "MIDIAnimator" 9 | 10 | class VIEW3D_PT_edit_instrument_information(MIDIAniamtorPanel, bpy.types.Panel): 11 | bl_label = "Edit Instrument Information" 12 | 13 | @classmethod 14 | def poll(cls, context): 15 | selectedObjs = context.selected_editable_objects 16 | # return True if collection is selected in outliner 17 | return len(selectedObjs) == 0 18 | 19 | 20 | def draw(self, context): 21 | if len(context.selected_editable_objects) != 0: 22 | blCol = context.object.users_collection[0] 23 | else: 24 | # fallback if empty collection 25 | blCol = context.collection 26 | 27 | blColMidi = blCol.midi 28 | 29 | layout = self.layout 30 | layout.use_property_decorate = False 31 | layout.use_property_split = True 32 | col = layout.column() 33 | 34 | col.label(text=f"Active collection: '{blCol.name}'") 35 | col.prop(blColMidi, "instrument_type", text="Instrument Type") 36 | 37 | for item in Instruments: 38 | value = item.value 39 | if value.identifier == blColMidi.instrument_type: 40 | value.cls.drawInstrument(context, col, blCol) 41 | break 42 | 43 | 44 | class VIEW3D_PT_edit_object_information(MIDIAniamtorPanel, bpy.types.Panel): 45 | bl_label = "Edit Object Information" 46 | 47 | @classmethod 48 | def poll(cls, context): 49 | selectedObjs = context.selected_editable_objects 50 | # return if object is selected 51 | return len(selectedObjs) != 0 52 | 53 | def draw(self, context): 54 | obj = context.active_object 55 | objMidi = obj.midi 56 | 57 | blCol = obj.users_collection[0] 58 | blColMidi = blCol.midi 59 | 60 | layout = self.layout 61 | layout.use_property_decorate = False 62 | layout.use_property_split = True 63 | 64 | col = layout.column() 65 | 66 | col.label(text=f"Active object: '{obj.name}'") 67 | 68 | 69 | for item in Instruments: 70 | value = item.value 71 | if value.identifier == blColMidi.instrument_type: 72 | value.cls.drawObject(context, col, obj) 73 | # if the property exists (precheck for next cond.) and is false or if the property does not exist at all, draw the note number object 74 | if (hasattr(value.cls, "EXCLUDE_NOTE_NUMBER") and not value.cls.EXCLUDE_NOTE_NUMBER) or not hasattr(value.cls, "EXCLUDE_NOTE_NUMBER"): 75 | col.prop(objMidi, "note_number") 76 | break 77 | 78 | 79 | class VIEW3D_PT_add_notes_quick(MIDIAniamtorPanel, bpy.types.Panel): 80 | bl_label = "Assign Notes to Objects" 81 | 82 | def draw(self, context): 83 | layout = self.layout 84 | layout.use_property_decorate = False 85 | layout.use_property_split = True 86 | col = layout.column() 87 | 88 | scene = context.scene 89 | sceneMidi = scene.midi 90 | 91 | col.prop(sceneMidi, "quick_note_number_list") 92 | col.prop(sceneMidi, "quick_obj_col") 93 | col.prop(sceneMidi, "quick_sort_by_name") 94 | 95 | col.separator_spacer() 96 | col.operator("scene.quick_add_props", text="Run") 97 | col.separator_spacer() 98 | -------------------------------------------------------------------------------- /MIDIAnimator/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from io import StringIO 3 | 4 | # Create and configure the logger 5 | logger = logging.getLogger('MIDIAnimator') 6 | logger.setLevel(logging.INFO) 7 | buffer = StringIO() 8 | 9 | # clear the handlers from the logger before adding new ones 10 | logger.handlers.clear() 11 | 12 | # Create a custom log formatter 13 | class ColoredFormatter(logging.Formatter): 14 | COLORS = { 15 | 'DEBUG': '\033[36m', # Cyan 16 | 'INFO': '\033[32m', # Green 17 | 'WARNING': '\033[33m', # Yellow 18 | 'ERROR': '\033[31m', # Red 19 | 'CRITICAL': '\033[1;31m' # Bold Red 20 | } 21 | RESET = '\033[0m' 22 | 23 | def format(self, record): 24 | levelname = record.levelname 25 | if levelname in self.COLORS: 26 | record.levelname = f'{self.COLORS[levelname]}{levelname}{self.RESET}' 27 | return super().format(record) 28 | 29 | # add two stream handlers, one to the buffer and one to the console 30 | bufferStream = logging.StreamHandler(buffer) 31 | bufferStream.setFormatter(logging.Formatter('%(name)s - %(levelname)s - %(message)s')) 32 | logger.addHandler(bufferStream) 33 | 34 | consoleStream = logging.StreamHandler() 35 | consoleStream.setFormatter(ColoredFormatter('%(name)s - %(levelname)s - %(message)s')) 36 | logger.addHandler(consoleStream) 37 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | html: Makefile 18 | python3 build_api_docs.py -f -o api ../MIDIAnimator 19 | python3 -m sphinx -T -E -b html -d $(BUILDDIR)/doctrees . $(BUILDDIR)/html 20 | 21 | clean: Makefile 22 | rm -f api/*.rst 23 | @echo "Removing everything under api/*.rst" 24 | $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 25 | -------------------------------------------------------------------------------- /docs/api/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/api/.gitkeep -------------------------------------------------------------------------------- /docs/build_api_docs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import sys 5 | import os 6 | 7 | os.system('python3 -m pip install -r docs/requirements.txt') 8 | 9 | from sphinx.ext.apidoc import main 10 | 11 | if __name__ == '__main__': 12 | sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) 13 | localDir = sys.argv[3] 14 | main() 15 | 16 | # because you cannot change how autodoc works, I wrote a custom stripper for the API docs that gets called before the docs are built. 17 | # This is a bit of a hack, but it works. 18 | 19 | for filename in os.listdir(localDir): 20 | if ".libs." in filename\ 21 | or "MIDIAnimator.rst" == filename\ 22 | or "modules.rst" == filename: 23 | os.remove(os.path.join(localDir, filename)) 24 | 25 | # now open the files up and edit them 26 | # the first line of the file will be the title of the module. We want to replace " package" with nothing, and "MIDIAnimator." with nothing. Still keep the old name (With the " package" replacement), as we want to check this against other lines 27 | # if the first two characters are "..", don't do anything (as this is an RST module), write the line and continue 28 | # if the line contains "Submodules" and the next line after that contains a dash, do not write either of those lines 29 | # if any line contains the first line of the file as described above + ".", replace it with nothing 30 | 31 | for filename in os.listdir(localDir): 32 | if ".libs." in filename\ 33 | or "MIDIAnimator.rst" == filename\ 34 | or "modules.rst" == filename: 35 | continue 36 | with open(os.path.join(localDir, filename), 'r') as f: 37 | lines = f.readlines() 38 | 39 | contains = False 40 | title = "" 41 | with open(os.path.join(localDir, filename), 'w') as f: 42 | for i, line in enumerate(lines): 43 | if i == 0: 44 | title = line.replace(" package", "") 45 | f.write(title.replace("MIDIAnimator.", "")) 46 | title = title.strip() 47 | continue 48 | if line.startswith(".."): 49 | f.write(line) 50 | continue 51 | if "Submodules" in line: 52 | if lines[lines.index(line) + 1].startswith("-"): 53 | contains = True 54 | continue 55 | 56 | if contains: 57 | contains = False 58 | continue 59 | 60 | if title in line: 61 | line = line.replace(title + ".", "") 62 | if " module" in line: 63 | # replace with ".py" 64 | line = line.replace(" module", ".py") 65 | f.write(line) 66 | 67 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | 3 | import os 4 | import sys 5 | import re 6 | import datetime 7 | 8 | print(f"cwd={os.getcwd()}") 9 | sys.path.insert(0, os.path.abspath("../")) 10 | 11 | from MIDIAnimator import bl_info 12 | 13 | # -- Project information 14 | 15 | project = 'MIDIAnimator' 16 | copyright = f'{datetime.date.today().year}, James Alt' 17 | author = 'James Alt' 18 | 19 | release = bl_info['name'].split(" ")[-1] 20 | version = bl_info['name'].split(" ")[-1] 21 | 22 | # -- General configuration 23 | 24 | extensions = [ 25 | 'sphinx.ext.duration', 26 | 'sphinx.ext.doctest', 27 | 'sphinx.ext.autodoc', 28 | 'sphinx.ext.autosummary', 29 | 'sphinx.ext.intersphinx', 30 | 'myst_parser' 31 | ] 32 | 33 | intersphinx_mapping = { 34 | 'python': ('https://docs.python.org/3/', None), 35 | 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), 36 | } 37 | intersphinx_disabled_domains = ['std'] 38 | 39 | templates_path = ['_templates'] 40 | 41 | # -- Options for HTML output 42 | 43 | html_theme = 'sphinx_rtd_theme' 44 | 45 | # -- Options for EPUB output 46 | epub_show_urls = 'footnote' 47 | 48 | autodoc_mock_imports = ["MIDIAnimator.libs", "numpy"] 49 | 50 | autodoc_class_signature = "separated" 51 | 52 | add_module_names = False 53 | 54 | exclude_patterns = ['api/modules.rst', 'api/MIDIAnimator.rst', 'api/MIDIAnimator.ui.rst'] 55 | 56 | master_doc = 'index' 57 | 58 | 59 | # thank you to https://github.com/sphinx-doc/sphinx/issues/4065#issuecomment-538535280 60 | def strip_signatures(app, what, name, obj, options, signature, return_annotation): 61 | sig = None 62 | if signature is not None: 63 | sig = re.sub('MIDIAnimator\.[^.]*\.', '', signature) 64 | 65 | ret = None 66 | if return_annotation is not None: 67 | ret = re.sub('MIDIAnimator\.[^.]*\.', '', signature) 68 | 69 | return sig, ret 70 | 71 | 72 | def setup(app): 73 | app.connect('autodoc-process-signature', strip_signatures) -------------------------------------------------------------------------------- /docs/demo_scene/demo.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/demo_scene/demo.mid -------------------------------------------------------------------------------- /docs/demo_scene/demo_scene.blend: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/demo_scene/demo_scene.blend -------------------------------------------------------------------------------- /docs/general/animation_types.md: -------------------------------------------------------------------------------- 1 | # Instrument Animation Types 2 | 3 | ## Evaluate 4 | "evaluates" an animation, either via a object or a procedural function. 5 | 6 | 7 | ### Instrument level features: 8 | 9 | *None* 10 | 11 | ### Object level features: 12 | * Note input (both MIDI note (60) and Note Name (C3) supported) 13 | * Individualized Note On/Off animation input 14 | * Object animation types (Keyframed, Dampned Oscillation generator, ADSR envelope generator) 15 | * Time mappers 16 | * Amplitude mappers 17 | * Velocity intensity slider 18 | * Animation overlap handling (additive) 19 | 20 |
21 | 22 | ## Projectile 23 | 24 | Pre-defined animation code that emulates a projectile launching. Smart caching feautres to only use enough objects to animate all projectiles. 25 | 26 | The collection that you assign this instrument type to is the "funnel" collection, or where you want the projectiles to initally start from. 27 | 28 | ### Instrument level features: 29 | * Projectile Collection (where the projectile objects get stored) 30 | * Reference Projectile (the object that gets copied to create the projectiles) 31 | * Use Inital Location (whether or not to use the initial location of the projectile curves) 32 | * Use Angle Based Location: 33 | * This allows you to use external objects to determine the angle of the projectile. 34 | * Offset (in degrees) 35 | * Location Collection (where the angle objects get stored. Must also have proper note numbers for each cooresponding funnel) 36 | 37 | ### Object level features: 38 | * Note input (both MIDI note (60) and Note Name (C3) supported) 39 | * Projectile Curve (the reference curves that get copied to create the projectiles) 40 | * Hit Time (where the ball hits the object. This will offset the animation in frames. - for earlier, + for later.) 41 | 42 |
43 | 44 | ## Custom: 45 | 46 | Defined by user, class and other parameters passed in via `addInstrument()`. 47 | More details coming soon. 48 | 49 | ```{note} 50 | More animation types will be added in the future. 51 | ``` 52 | -------------------------------------------------------------------------------- /docs/general/breakdown.md: -------------------------------------------------------------------------------- 1 | # Breakdown 2 | 3 | ## How do we drive music animation? 4 | 5 | There are 2 different approaches to driving a music animation: 6 | 7 | 1. FFT based approach- where you input an audio file (.wav, .mp3, etc) and have the visual elements react to the audio data. The audio data is filtered with steep inverse notch filters on selected frequencies. With the filtered data, you can then use the amplitude of the signal to drive animation parameters. This feature is [built into Blender.](https://docs.blender.org/manual/en/latest/editors/graph_editor/fcurves/editing.html#bake-sound-to-f-curves) 8 | 9 | 2. Preactive approach using a MIDI file. A MIDI file is read in and broken down into its components. Animation curves get “copied” throughout the duration of the MIDI file, based on sets of parameters, typically determined by the notes in the MIDI file. The MIDI file determines when animations are being copied. This is the way MIDIAnimator works. 10 | 11 | ## Process of creating a MIDI animation with MIDIAnimator (front/backend): 12 | MIDI files are broken down using the `MIDIFile()` class. `MIDIFile()` takes 1 parameter, which is the file where the MIDI file is stored. 13 | 14 | `MIDIFile()` will create `MIDITrack()`, `MIDINote()` and `MIDIEvent()` objects based on the data inside of the MIDI file. 15 | 16 | Structure of a `MIDIFile()` object: 17 | 18 | ``` 19 | MIDIFile: 20 | file: string (where the MIDI file is stored) 21 | tracks: list of MIDITrack objects 22 | 23 | MIDITrack: 24 | name: string 25 | notes: list of MIDINote objects 26 | control change: associative array that maps a control change number to a list of MIDIEvent values 27 | aftertouch: list of MIDIEvent values 28 | pitchwheel: list of MIDIEvent values 29 | 30 | MIDINote: 31 | channel: integer 32 | noteNumber: integer 33 | velocity: integer 34 | timeOn: float, in seconds 35 | timeOff: float, in seconds 36 | 37 | MIDIEvent: 38 | channel: integer 39 | value: float 40 | time: float, in seconds 41 | ``` 42 | 43 | To get specific `MIDITrack` objects, use the `MIDIFile.findTrack()` method. 44 | 45 | To start adding instruments, instance a `MIDIAnimatorNode()` object 46 | 47 | * Use the `MIDIAnimatorNode.addInsturment()` method to add an instrument. 48 | * Takes a `MIDITrack`, `bpy.types.Collection`. 49 | 50 | Call the `MIDIAnimatorNode.animate()` method to animate all instruments. 51 | 52 | * Given each instrument in the `MIDIAnimatorNode()` instruments list: 53 | * `preAnimate()` is called 54 | * `pass` by default. 55 | * `instrument.animate()` gets called for every note in the MIDIFile 56 | * Takes note and caclulates its keyframe data for it (applies time mappers, velocity, etc.) 57 | * Adds together in the list of already created keyframes 58 | 59 | ## Dealing with overlapping animation: 60 | In Figure A, we are given a simple dampened oscillation function. 61 | 62 |
63 | images/breakdown_figurea.png 64 | 65 | *Figure A, simple dampened oscillation* 66 |
67 | 68 | If we were to animate Figure A using a simple MIDI file (2 notes, with the notes overlapping), it would look something like Figure B. 69 | 70 | However, there is a fundamental problem, as there is overlapping animation (denoted by the question mark in Figure B). 71 | 72 |
73 | images/breakdown_figureb.png 74 | 75 | *Figure B, animation duplicated across timeline* 76 |
77 | 78 | 79 | To deal with the overlapping animation, we can simply add the 2 animation curves together, shown in Figure C. 80 | 81 |
82 | images/breakdown_figurec.png 83 | 84 | *Figure C, resulting animation curve* 85 |
86 | 87 | 88 | ### Problems with this approach: 89 | If the motion is not oscillating, the motion will be added together, in a result which is not desirable. 90 | More techniques in dealing with overlapping animation will need to be researched. 91 | -------------------------------------------------------------------------------- /docs/general/future_plans.md: -------------------------------------------------------------------------------- 1 | # Future Plans 2 | 3 | - Future roadmap can be found [here](https://github.com/jamesa08/MIDIAnimator/projects). -------------------------------------------------------------------------------- /docs/general/installation.md: -------------------------------------------------------------------------------- 1 | (installation)= 2 | 3 | # Installation 4 | 5 | You will need to install Blender (version 3.0 or better) to use MIDI Animator. 6 | 7 | ## Download 8 | Download the `.zip` file in the Releases pane, or click [here.](https://github.com/imacj/MIDIAnimator/releases) 9 | 10 | ```{admonition} Warning 11 | :class: danger 12 | 13 | Do NOT unzip the file. If the zip file is unzipped, the add-on will install improperly. 14 | ``` 15 | 16 | ## Install 17 | In Blender, go to Edit -> Preferences 18 | 19 | ![inst1](https://raw.githubusercontent.com/jamesa08/MIDIAnimatorDocs/main/docs/images/inst_1.png) 20 | 21 | Next, go to Add-ons -> Install... 22 | 23 | ![inst2](https://raw.githubusercontent.com/jamesa08/MIDIAnimatorDocs/main/docs/images/inst_2.png) 24 | 25 | Locate the `.zip` file. 26 | 27 | Make sure to enable the add-on by pressing the checkbox. 28 | ![inst3](https://raw.githubusercontent.com/jamesa08/MIDIAnimatorDocs/main/docs/images/inst_3.png) 29 | 30 | *Volia!* You have now successfully installed MIDI Animator. You should see the "MIDIAnimator" tab now in the 3D viewport. 31 | 32 | ![inst4](https://raw.githubusercontent.com/jamesa08/MIDIAnimatorDocs/main/docs/images/inst_4.png) 33 | 34 | 35 | ## Next Steps 36 | - **Check out the [Getting Started](/general/getting_started.md) page.** 37 | - **Do the [simple tutorial](/tutorials/tutorial.md).** 38 | - **Do the [advanced tutorial](/tutorials/adv_tutorial.md).** 39 | -------------------------------------------------------------------------------- /docs/images/breakdown_figurea.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/breakdown_figurea.png -------------------------------------------------------------------------------- /docs/images/breakdown_figureb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/breakdown_figureb.png -------------------------------------------------------------------------------- /docs/images/breakdown_figurec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/breakdown_figurec.png -------------------------------------------------------------------------------- /docs/images/cube_anim_note_anchor_pt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/cube_anim_note_anchor_pt.png -------------------------------------------------------------------------------- /docs/images/cube_anim_properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/cube_anim_properties.png -------------------------------------------------------------------------------- /docs/images/cube_note_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/cube_note_example.png -------------------------------------------------------------------------------- /docs/images/cube_sorting_sort_name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/cube_sorting_sort_name.png -------------------------------------------------------------------------------- /docs/images/cube_sorting_syntax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/cube_sorting_syntax.png -------------------------------------------------------------------------------- /docs/images/inst_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/inst_1.png -------------------------------------------------------------------------------- /docs/images/inst_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/inst_2.png -------------------------------------------------------------------------------- /docs/images/inst_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/inst_3.png -------------------------------------------------------------------------------- /docs/images/inst_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/docs/images/inst_4.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | [contributors-shield]: https://img.shields.io/github/contributors/imacj/MIDIAnimator.svg?style=flat 2 | [contributors-url]: https://github.com/imacj/MIDIAnimator/graphs/contributors 3 | [forks-shield]: https://img.shields.io/github/forks/imacj/MIDIAnimator.svg?style=flat 4 | [forks-url]: https://github.com/imacj/MIDIAnimator/network/members 5 | [stars-shield]: https://img.shields.io/github/stars/imacj/MIDIAnimator.svg?style=flat 6 | [stars-url]: https://github.com/imacj/MIDIAnimator/stargazers 7 | [issues-shield]: https://img.shields.io/github/issues/imacj/MIDIAnimator.svg?style=flat 8 | [issues-url]: https://github.com/imacj/MIDIAnimator/issues 9 | [license-shield]: https://img.shields.io/github/license/imacj/MIDIAnimator.svg?style=flat 10 | [license-url]: https://github.com/imacj/MIDIAnimator/blob/master/LICENSE.txt 11 | [product-screenshot]: images/screenshot.png 12 | 13 | # MIDI Animator 14 | 15 | ```{note} 16 | This project is under active development. These documents are subject to change, and things may have unexpected results. 17 | 18 | Documentation is unfinished and is always being updated. 19 | ``` 20 | 21 | [![Contributors][contributors-shield]][contributors-url] 22 | [![Forks][forks-shield]][forks-url] 23 | [![Stargazers][stars-shield]][stars-url] 24 | [![Issues][issues-shield]][issues-url] 25 | [![GNU License][license-shield]][license-url] 26 | 27 | ## About the project: 28 | **MIDI Animator** aims to provide a cohesive, open-source solution to animating instruments using a MIDI file. 29 | 30 | ```{admonition} Note 31 | 32 | 33 | Currently unsupported: 34 | - Sustaining notes 35 | - Using MIDI CC data & pitchwheel data 36 | - Bones (for rigs). Only object parent trees are supported 37 | 38 | ``` 39 | 40 | *You can download an offline version of the docs [here.](https://midianimatordocs.readthedocs.io/_/downloads/en/latest/pdf/)* 41 | 42 | ## Getting Started: 43 | 44 | Check out how to [install](general/installation.md) the project. See {ref}`getting_started` to get started. 45 | 46 | ## Contributing 47 | 48 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 49 | 50 | If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". 51 | Don't forget to give the project a star! Thanks again! 52 | 53 | 1. Fork the Project 54 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 55 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 56 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 57 | 5. Open a Pull Request 58 | 59 | ## License 60 | 61 | Distributed under the GNU General Public License (GPLv3) license.
62 | You may freely change and add to a forked repository as you wish, but you may **not** publish this software as closed source.
63 | _See `LICENSE.txt` for more information._
64 | 65 | ## Contact 66 | 67 | James Alt - [jalt@capital.edu](mailto:jalt@capital.edu) 68 | 69 | Project Link: [https://github.com/imacj/MIDIAnimator](https://github.com/imacj/MIDIAnimator) 70 | 71 | ## Acknowledgments 72 | 73 | Here are some of the development tools I used to create this project. 74 | 75 | - [Visual Studio Code](https://code.visualstudio.com) 76 | - [Blender Development Addon](https://marketplace.visualstudio.com/items?itemName=JacquesLucke.blender-development) 77 | - [Fake Blender Python API Module (for code completion)](https://github.com/nutti/fake-bpy-module) 78 | - [Blender Python API Documentation](https://docs.blender.org/api/2.91/) 79 | 80 | Thank you to David Reed, Professor of Computer Science at Capital University advising this project and assisting with algorithms and data structures. 81 | 82 | 83 | 84 | ```{toctree} 85 | :caption: Table of Contents 86 | :maxdepth: 1 87 | 88 | general/installation.md 89 | general/getting_started.md 90 | general/breakdown.md 91 | general/animation_types.md 92 | tutorials/tutorial.md 93 | tutorials/adv_tutorial.md 94 | general/future_plans.md 95 | ``` 96 | 97 | ```{toctree} 98 | :caption: API Reference 99 | :maxdepth: 1 100 | :glob: 101 | 102 | api/* 103 | 104 | ``` -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==4.5.0 2 | sphinx-rtd-theme==1.0.0 3 | 4 | myst-parser==0.17.2 # via my-st 5 | mypy-extensions==0.4.3 # via my-st 6 | mdit-py-plugins==0.3.0 # via my-st 7 | fake-bpy-module-latest 8 | numpy -------------------------------------------------------------------------------- /docs/tutorials/adv_tutorial.md: -------------------------------------------------------------------------------- 1 | 14 | 15 | # Advanced Tutorial 16 | 17 | ```{note} 18 | This section is under development. Check back later. 19 | ``` -------------------------------------------------------------------------------- /docs/tutorials/tutorial.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | # Simple Tutorial 15 | 16 | ```{note} 17 | This section is under development. Check back later. 18 | ``` -------------------------------------------------------------------------------- /noteOffData.py: -------------------------------------------------------------------------------- 1 | from typing import List, Tuple 2 | from dataclasses import dataclass 3 | 4 | @dataclass 5 | class Keyframe: 6 | frame: float 7 | value: float 8 | 9 | def findOverlap(keyList1, keyList2): 10 | if len(keyList2) == 0: 11 | return [] 12 | 13 | if keyList1[0].frame > keyList2[0].frame: 14 | #this means a note is somehow going back in time? is this even possible? 15 | # notes should always be sequential, and not in reverse time 16 | raise ValueError("first keyframe in keyList1 is bigger than first keyframe in keyList2! Please open a issue on GitHub along with the MIDI file.") 17 | 18 | overlappingKeyList = [] 19 | overlapping = False 20 | for key1 in reversed(keyList1): 21 | # print ("testing case key1={keyl.frame} and key2={keyList2[0].frame}") 22 | if key1.frame > keyList2[0].frame: 23 | overlapping = True 24 | overlappingKeyList.append(key1) 25 | else: 26 | # not overlapping 27 | if overlapping: 28 | overlappingKeyList.append(key1) 29 | break 30 | 31 | return list(reversed(overlappingKeyList)) 32 | 33 | def getValue(key1: Keyframe, key2: Keyframe, frame: float) -> float: 34 | x1, y1 = key1.frame, key1.value 35 | x2, y2 = key2.frame, key2.value 36 | try: 37 | m = (y2 - y1) / (x2 - x1) 38 | except ZeroDivisionError: 39 | # i dont know if this will work every time 40 | m = 0 41 | 42 | c = y1 - m * x1 43 | return (m * frame) + c 44 | 45 | def interval(keyList, frame): 46 | if len(keyList) == 0: 47 | return (None, None) 48 | if keyList[0].frame > frame: 49 | # out of range to the left of the list 50 | return (keyList[0], keyList[0]) 51 | elif keyList[-1].frame < frame: 52 | # out of range to the right of the list 53 | return (keyList[-1], keyList[-1]) 54 | 55 | for i in range(len(keyList)): 56 | if keyList[i].frame <= frame <= keyList[i+1].frame: 57 | return (keyList[i], keyList[i+1]) 58 | 59 | 60 | x = 0 61 | keyList1 = [Keyframe(frame=59, value=0.0), Keyframe(frame=74, value=0.5), Keyframe(frame=77, value=0.5), Keyframe(frame=92, value=0.0)] 62 | keyList2 = [Keyframe(frame=89-x, value=0.0), Keyframe(frame=104-x, value=0.5), Keyframe(frame=107-x, value=0.5), Keyframe(frame=122-x, value=0.0)] 63 | 64 | keyList1Overlapping = findOverlap(keyList1, keyList2) 65 | 66 | key1InterpolatedValues = [] 67 | key2InterpolatedValues = [] 68 | 69 | # interpolate the keyframes for each graph 70 | for key2 in keyList2: 71 | key1interval1, key1interval2 = interval(keyList1Overlapping, key2.frame) 72 | if key1interval1 is None and key1interval2 is None: 73 | continue 74 | key2InterpolatedValues.append(Keyframe(key2.frame, getValue(key1interval1, key1interval2, key2.frame))) 75 | 76 | for key1 in keyList1Overlapping: 77 | key2interval1, key2interval2 = interval(keyList2, key1.frame) 78 | if key2interval1 is None and key2interval2 is None: 79 | continue 80 | key1InterpolatedValues.append(Keyframe(key1.frame, getValue(key2interval1, key2interval2, key1.frame))) 81 | 82 | # now add the keyframe values together (the most important part) 83 | for key1, key1Interp in zip(keyList1Overlapping, key1InterpolatedValues): 84 | key1.value += key1Interp.value 85 | 86 | for key2, key2Interp in zip(keyList2, key2InterpolatedValues): 87 | key2.value += key2Interp.value 88 | 89 | # extend the lists (need a better method to ensure the keyframes before get cut off and then start ) 90 | keyList1Overlapping.extend(keyList2) 91 | 92 | keyList1Overlapping.sort(key=lambda x: x.frame) 93 | 94 | print("x, y") 95 | for key in keyList1Overlapping: 96 | print(f"{key.frame}, {key.value}") 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" -------------------------------------------------------------------------------- /readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | python: 7 | install: 8 | - requirements: docs/requirements.txt 9 | 10 | submodules: 11 | include: all 12 | recursive: true 13 | 14 | build: 15 | os: ubuntu-22.04 16 | tools: 17 | python: "3.11" 18 | jobs: 19 | pre_create_environment: 20 | - echo "Update autodocs" 21 | - python3 docs/build_api_docs.py -f -o docs/api MIDIAnimator -------------------------------------------------------------------------------- /rust-impl/blender-addon/__init__.py: -------------------------------------------------------------------------------- 1 | # This program is free software; you can redistribute it and/or modify 2 | # it under the terms of the GNU General Public License as published by 3 | # the Free Software Foundation; either version 3 of the License, or 4 | # (at your option) any later version. 5 | # 6 | # This program is distributed in the hope that it will be useful, but 7 | # WITHOUT ANY WARRANTY; without even the implied warranty of 8 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 9 | # General Public License for more details. 10 | # 11 | # You should have received a copy of the GNU General Public License 12 | # along with this program. If not, see . 13 | 14 | from __future__ import annotations 15 | from . ui import VIEW3D_PT_server_link, SCENE_OT_connect_to_server, SCENE_OT_disconnect_from_server 16 | from . src.core import Server 17 | import bpy 18 | 19 | bl_info = { 20 | "name": "MIDIAnimator Bridge beta5.0", 21 | "description": "Bridge between MIDIAnimator and Blender", 22 | "author": "James Alt (et al.)", 23 | "version": (0, 5, 0), 24 | "blender": (3, 0, 0), 25 | "location": "Scripting Space", 26 | "doc_url": "https://midianimatordocs.readthedocs.io/en/latest/", 27 | "tracker_url": "https://github.com/jamesa08/MIDIAnimator/issues", 28 | "warning": "MIDIAnimator is currently in beta. If you encounter any issues, please feel free to open an issue on GitHub (https://github.com/jamesa08/MIDIAnimator/issues)", 29 | "support": "COMMUNITY", 30 | "category": "Animation" 31 | } 32 | 33 | classes = (VIEW3D_PT_server_link, SCENE_OT_connect_to_server, SCENE_OT_disconnect_from_server) 34 | 35 | # verify singleton 36 | s1 = Server() 37 | s2 = Server() 38 | 39 | if id(s1) == id(s2): 40 | print(f"MIDIAnimator Bridge: verified singleton, debug id: {id(s1)}") 41 | else: 42 | raise RuntimeError("MIDIAnimator Bridge: failed to verify singleton. Please open an isuse on GitHub.") 43 | 44 | def register(): 45 | for bpyClass in classes: 46 | bpy.utils.register_class(bpyClass) 47 | 48 | 49 | def unregister(): 50 | for bpyClass in classes: 51 | bpy.utils.unregister_class(bpyClass) 52 | 53 | # close the client connection when the addon is unregistered 54 | client = Server() 55 | client.close() 56 | -------------------------------------------------------------------------------- /rust-impl/blender-addon/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/blender-addon/src/__init__.py -------------------------------------------------------------------------------- /rust-impl/blender-addon/ui/__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .. src.core import Server 3 | 4 | class SCENE_OT_connect_to_server(bpy.types.Operator): 5 | bl_idname = "scene.connect_to_server" 6 | bl_label = "Connect to Server" 7 | bl_description = "Connect to the server at port 6577" 8 | 9 | def ping(self): 10 | pass 11 | 12 | def execute(self, context): 13 | client = Server() 14 | res = client.open() 15 | if not res: 16 | self.report({"ERROR"}, "Could not connect to server. Make sure MIDIAnimator is running.") 17 | return {"FINISHED"} 18 | 19 | class SCENE_OT_disconnect_from_server(bpy.types.Operator): 20 | bl_idname = "scene.disconnect_from_server" 21 | bl_label = "Disconnect from Server" 22 | bl_description = "Disconnect from the server at port 6577" 23 | 24 | def execute(self, context): 25 | client = Server() 26 | client.close() 27 | return {"FINISHED"} 28 | 29 | class MIDIAniamtorPanel: 30 | bl_space_type = "VIEW_3D" 31 | bl_region_type = "UI" 32 | bl_category = "MIDIAnimator Link" 33 | 34 | class VIEW3D_PT_server_link(MIDIAniamtorPanel, bpy.types.Panel): 35 | bl_label = "MIDIAnimator Link" 36 | 37 | @classmethod 38 | def poll(cls, context): 39 | return True 40 | 41 | def draw(self, context): 42 | client = Server() 43 | 44 | layout = self.layout 45 | layout.use_property_decorate = False 46 | layout.use_property_split = True 47 | 48 | col = layout.column() 49 | col.label(text="Client at port 6577") 50 | 51 | if client.connected: 52 | col.label(text="Connected to Server") 53 | col.operator("scene.disconnect_from_server", text="Disconnect") 54 | else: 55 | col.label(text="Not connected to Server") 56 | col.operator("scene.connect_to_server", text="Connect") 57 | # if context.scene.connect_to_server == True: 58 | # col.label(text="Connected to Server") 59 | # col.prop(context.scene, "server_link", text="Server Link") 60 | 61 | -------------------------------------------------------------------------------- /rust-impl/midianimator/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /rust-impl/midianimator/README.md: -------------------------------------------------------------------------------- 1 | ## Front-end only: 2 | 3 | `cd` into `/rust-impl/midianimator/` 4 | 5 | `npm run build` to build 6 | 7 | `npm run dev` to run development server 8 | 9 | --- 10 | 11 | ## Back-end only: 12 | 13 | `cd` into `/rust-impl/midianimator/src-tauri/` 14 | 15 | `cargo build` to build 16 | 17 | `cargo run` to run `main.rs` 18 | 19 | --- 20 | 21 | ## Complete app: 22 | 23 | `cd` into `/rust-impl/midianimator/` 24 | 25 | `npm run tauri dev` to run Tauri development server 26 | 27 | `npm run bundle` to package up Tauri application into installers 28 | 29 | 30 | ## Built with the following technologies: 31 | 32 | - Rust 33 | - midly 34 | - Typescript/Javascript 35 | - Python 36 | - Tauri 37 | - drag-rs 38 | - Vite 39 | - React.js 40 | - react-router 41 | - React Flow 42 | - TailwindCSS -------------------------------------------------------------------------------- /rust-impl/midianimator/custom.d.ts: -------------------------------------------------------------------------------- 1 | // from https://stackoverflow.com/questions/44717164/unable-to-import-svg-files-in-typescript 2 | declare module "*.svg" { 3 | const content: any; 4 | export default content; 5 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | MIDIAnimator 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /rust-impl/midianimator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "midianimator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@tauri-apps/api": "^1.5.5", 7 | "@types/jest": "^29.5.12", 8 | "@types/node": "^20.12.2", 9 | "@xyflow/react": "^12.0.1", 10 | "autoprefixer": "^10.4.20", 11 | "postcss": "^8.4.38", 12 | "react": "^18.3.1", 13 | "react-dom": "^18.3.1", 14 | "react-router-dom": "^6.23.1", 15 | "rete": "^2.0.3", 16 | "rete-area-plugin": "^2.0.4", 17 | "rete-connection-plugin": "^2.0.1", 18 | "rete-react-plugin": "^2.0.5", 19 | "rete-render-utils": "^2.0.2", 20 | "styled-components": "^6.1.8", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "dev": "vite", 25 | "build": "tsc && vite build", 26 | "preview": "vite preview", 27 | "tauri": "tauri", 28 | "bundle": "tauri build" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@tauri-apps/cli": "^1.5.11", 50 | "@types/react": "^18.3.2", 51 | "@types/react-dom": "^18.3.0", 52 | "@vitejs/plugin-react": "^4.3.0", 53 | "postcss-cli": "^11.0.0", 54 | "tailwindcss": "^3.4.3", 55 | "typescript": "^5.2.2", 56 | "vite": "^5.2.11" 57 | }, 58 | "overrides": { 59 | "react-scripts": { 60 | "typescript": "^5.4.5" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /rust-impl/midianimator/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /rust-impl/midianimator/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /rust-impl/midianimator/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /src/auto_commands.rs 5 | /src/node_registry.rs -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "MIDIAnimator" 3 | version = "0.5.0" 4 | description = "Procedurally animate a MIDI file. version beta5" 5 | authors = ["you"] 6 | license = "GPL-3.0" 7 | repository = "https://github.com/jamesa08/MIDIAnimator" 8 | default-run = "MIDIAnimator" 9 | edition = "2021" 10 | rust-version = "1.60" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [build-dependencies] 15 | tauri-build = { version = "1.5.1", features = [] } 16 | regex = "*" 17 | walkdir = "2.3" 18 | 19 | [dependencies] 20 | serde_json = "1.0" 21 | serde = { version = "1.0", features = ["derive"] } 22 | tauri = { version = "1.8.0", features = ["api-all"] } 23 | regex = "1.5.4" 24 | lazy_static = "1.4.0" 25 | midly = "0.5.3" 26 | uuid = "1.8.0" 27 | once_cell = "1.19.0" 28 | tokio = { version = "1", features = ["macros", "rt-multi-thread"] } 29 | async-recursion = "1.1.1" 30 | node_registry = { path = "./node_registry" } 31 | 32 | [features] 33 | # this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled. 34 | # If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes. 35 | # DO NOT REMOVE!! 36 | custom-protocol = [ "tauri/custom-protocol" ] 37 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/node_registry/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "node_registry" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [lib] 9 | proc-macro = true 10 | 11 | [dependencies] 12 | syn = { version = "1.0", features = ["full"] } 13 | quote = "1.0" 14 | proc-macro2 = "1.0" -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/node_registry/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::{parse_macro_input, ItemFn}; 4 | 5 | #[proc_macro_attribute] 6 | pub fn node(_attr: TokenStream, item: TokenStream) -> TokenStream { 7 | let input = parse_macro_input!(item as ItemFn); 8 | let name = &input.sig.ident; 9 | let vis = &input.vis; 10 | let block = &input.block; 11 | 12 | let output = quote! { 13 | #vis fn #name(inputs: std::collections::HashMap) -> std::collections::HashMap { 14 | #block 15 | } 16 | }; 17 | 18 | output.into() 19 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/blender/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod scene_data; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/blender/python/blender_scene_builder.py: -------------------------------------------------------------------------------- 1 | import json 2 | import bpy 3 | 4 | def shape_keys_from_object(obj): 5 | """gets shape keys from object 6 | 7 | :param bpy.types.Object object: the blender object 8 | :return tuple: returns a tuple, first element being a list of the shape keys and second element being the reference key 9 | """ 10 | # from Animation Nodes 11 | # https://github.com/JacquesLucke/animation_nodes/blob/7a74e31fca0e7fce6edefdb8183dc4ac9c5acbfc/animation_nodes/nodes/shape_key/shape_keys_from_object.py 12 | 13 | if obj is None: return [], None 14 | if obj.type not in ("MESH", "CURVE", "LATTICE"): return [], None 15 | if obj.data.shape_keys is None: return [], None 16 | 17 | reference = obj.data.shape_keys.reference_key 18 | return list(obj.data.shape_keys.key_blocks)[1:], reference 19 | 20 | def FCurvesFromObject(obj): 21 | """Gets FCurves (`bpy.types.FCurve`) from an object (`bpy.types.Object`). 22 | 23 | :param bpy.types.Object obj: the Blender object 24 | :return List[bpy.types.FCurve]: a list of `bpy.types.FCurve` objects. If the object does not have FCurves, it will return an empty list. 25 | """ 26 | if obj.animation_data is None: return [] 27 | if obj.animation_data.action is None: return [] 28 | 29 | return list(obj.animation_data.action.fcurves) 30 | 31 | def get_fcurve_data(fcurve): 32 | """Converts an FCurve into a dictionary representation. 33 | 34 | :param bpy.types.FCurve fcurve: the Blender FCurve 35 | :return dict: dictionary representing the FCurve 36 | """ 37 | keyframe_points = [ 38 | { 39 | "amplitude": key.amplitude, 40 | "back": key.back, 41 | "easing": key.easing, 42 | "handle_left": list(key.handle_left), 43 | "handle_left_type": key.handle_left_type, 44 | "handle_right": list(key.handle_right), 45 | "handle_right_type": key.handle_right_type, 46 | "interpolation": key.interpolation, 47 | "co": list(key.co), 48 | "period": key.period 49 | } 50 | for key in fcurve.keyframe_points 51 | ] 52 | 53 | return { 54 | "array_index": fcurve.array_index, 55 | "auto_smoothing": fcurve.auto_smoothing, 56 | "data_path": fcurve.data_path, 57 | "extrapolation": fcurve.extrapolation, 58 | "keyframe_points": keyframe_points, 59 | "range": list(fcurve.range()), 60 | } 61 | 62 | def get_all_objects_in_collection(collection, objects=None): 63 | if objects is None: 64 | objects = [] 65 | 66 | for obj in collection.objects: 67 | keys, ref = shape_keys_from_object(obj) 68 | obj_data = { 69 | "name": obj.name, 70 | "location": list(obj.location), 71 | "rotation": list(obj.rotation_euler), 72 | "scale": list(obj.scale), 73 | "blend_shapes": { 74 | "keys": [repr(key) for key in keys if key is not None], 75 | "reference": repr(ref) if ref is not None else None 76 | }, 77 | "anim_curves": [], 78 | } 79 | 80 | if obj.name.startswith("ANIM"): 81 | fcurves = FCurvesFromObject(obj) 82 | obj_data["anim_curves"] = [get_fcurve_data(fcurve) for fcurve in fcurves] 83 | 84 | objects.append(obj_data) 85 | 86 | for child in collection.children: 87 | objects = get_all_objects_in_collection(child, objects) 88 | 89 | return objects 90 | 91 | def execute(): 92 | scene_data = {} 93 | 94 | for scene in bpy.data.scenes: 95 | scene_key = f"{scene.name}" 96 | scene_data[scene_key] = {} 97 | scene_data[scene_key]["object_group"] = {} 98 | 99 | for collection in scene.collection.children: 100 | collection_key = f"{collection.name}" 101 | scene_data[scene_key]["object_group"][collection_key] = { 102 | "objects": get_all_objects_in_collection(collection) 103 | } 104 | return json.dumps(scene_data) 105 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/blender/python/blender_scene_sender.py: -------------------------------------------------------------------------------- 1 | JSON_DATA = r"""""" 2 | # JSON_DATA is a static variable that is injected when rust function send_scene_data() is called 3 | # also has to be a raw triple quote string 4 | 5 | import bpy 6 | import json 7 | 8 | def execute(): 9 | print("json", JSON_DATA) 10 | data = json.loads(JSON_DATA) 11 | 12 | for scene_name, scene_value in data.items(): 13 | print(f"Scene: {scene_name}") 14 | 15 | for object_group in scene_value['object_groups']: 16 | print(f" Object Group: {object_group['name']}") 17 | 18 | # Iterate over objects within each object group 19 | for obj in object_group['objects']: 20 | print(f" Object: {obj['name']}") 21 | print(f" Position: {obj['position']}") 22 | print(f" Rotation: {obj['rotation']}") 23 | print(f" Scale: {obj['scale']}") 24 | 25 | # If there are blend shapes 26 | blend_shapes = obj.get('blend_shapes', {}) 27 | if blend_shapes: 28 | print(f" Blend Shapes: {blend_shapes}") 29 | 30 | # If there are animation curves 31 | anim_curves = obj.get('anim_curves', []) 32 | if anim_curves: 33 | for curve in anim_curves: 34 | print(f" Animation Curve:") 35 | print(f" Array Index: {curve.get('array_index')}") 36 | print(f" Auto Smoothing: {curve.get('auto_smoothing')}") 37 | print(f" Data Path: {curve.get('data_path')}") 38 | print(f" Extrapolation: {curve.get('extrapolation')}") 39 | print(f" Range: {curve.get('range')}") 40 | 41 | # Iterate over keyframe points 42 | keyframe_points = curve.get('keyframe_points', []) 43 | for point in keyframe_points: 44 | print(f" Keyframe Point:") 45 | print(f" Amplitude: {point.get('amplitude')}") 46 | print(f" Back: {point.get('back')}") 47 | print(f" Easing: {point.get('easing')}") 48 | print(f" Handle Left: {point.get('handle_left')}") 49 | print(f" Handle Left Type: {point.get('handle_left_type')}") 50 | print(f" Handle Right: {point.get('handle_right')}") 51 | print(f" Handle Right Type: {point.get('handle_right_type')}") 52 | print(f" Interpolation: {point.get('interpolation')}") 53 | print(f" Coordinate: {point.get('co')}") 54 | print(f" Period: {point.get('period')}") 55 | 56 | return "OK" -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/command/event.rs: -------------------------------------------------------------------------------- 1 | use tauri::WindowMenuEvent; 2 | use serde_json::json; 3 | 4 | use crate::utils::ui::get_logical_size; 5 | 6 | pub fn open_settings(event: WindowMenuEvent) { 7 | // payload must conform to interface WindowOptions 8 | let win_size = get_logical_size(&event.window()); 9 | let payload = json!({ 10 | "title": "Settings", 11 | "url": "/#/settings", 12 | "x": win_size.width / 2 - 400, // place in the center of the screen 13 | "y": win_size.height / 2 - 300, 14 | "width": 800, 15 | "height": 600 16 | }); 17 | 18 | let res = event.window().emit("open-window", payload); 19 | 20 | if let Err(e) = res { 21 | eprintln!("Error creating settings window: {:?}", e); 22 | } 23 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/command/javascript.rs: -------------------------------------------------------------------------------- 1 | use crate::state::WINDOW; 2 | use std::sync::{Arc, Mutex}; 3 | use tauri::Manager; 4 | 5 | /// this function is used to evaluate javascript code on the window 6 | /// 7 | /// this is useful for executing javascript code from the backend & getting the result back, nice for dynamic code execution 8 | /// in order to use this function, you will need to pass in a string with a javascript function called `execute()` that returns some string'ed value. 9 | /// 10 | /// The returned value will be in JSON string format, so you will need to parse it. The result of the function is in a key called `result`. 11 | /// 12 | /// WARNING: this may change at any time. Sorry not sorry 13 | /// 14 | /// Example: 15 | /// ```rust 16 | /// let result = evaluate_js("function execute() { return 'hello world'; }".to_string()).await; 17 | /// println!("Result: {}", result); 18 | /// ``` 19 | pub async fn evaluate_js(code: String) -> String { 20 | let (tx, mut rx) = tokio::sync::mpsc::channel(1); 21 | 22 | // need to clone the listener id so we can remove it later 23 | let listener_id: Arc>> = Arc::new(Mutex::new(None)); 24 | let listener_id_clone = listener_id.clone(); 25 | 26 | tokio::spawn(async move { 27 | let window = WINDOW.lock().unwrap(); 28 | let random = uuid::Uuid::new_v4().to_string(); 29 | 30 | let wrapper_code = format!(r#"{0} 31 | (function() {{ 32 | try {{ 33 | execute().then((result) => {{ 34 | window.__TAURI__.window.appWindow.emit("__js_result_{1}", {{ result: result }}) 35 | }}); 36 | }} catch (error) {{ 37 | console.log("JS ERROR:", error); 38 | window.__TAURI__.window.appWindow.emit("__js_result_{1}", {{ result: JSON.stringify({{ error: error.toString() }}) }}) 39 | }} 40 | }})(); 41 | "#, 42 | code, 43 | random 44 | ); 45 | 46 | // eval javascript code blindly on the window. MUST be non-blocking for it to execute and for the event to get picked up (hence async) 47 | let _ = window.as_ref().unwrap().eval(&wrapper_code); 48 | 49 | 50 | let listener_handle = window.as_ref().unwrap().once_global(format!("__js_result_{0}", random), move |event| { 51 | if let Some(payload) = event.payload() { 52 | let _ = tx.try_send(payload.to_string()); 53 | } 54 | }); 55 | 56 | // Set the listener id so we can remove it later 57 | *listener_id.lock().unwrap() = Some(listener_handle); 58 | }); 59 | 60 | 61 | let result = rx.recv().await.unwrap(); 62 | 63 | if !result.is_empty() { 64 | let window = WINDOW.lock().unwrap(); 65 | 66 | // Remove the event listener 67 | window.as_ref().unwrap().unlisten(*listener_id_clone.lock().unwrap().as_ref().unwrap()); 68 | drop(window); 69 | 70 | return result; 71 | } else { 72 | return "".to_string(); 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | // api module info: 2 | // this module exposes the API to the front end 3 | // using [tauri::command] and possibly either lua or python scripts to interact with the backend 4 | 5 | pub mod event; 6 | pub mod javascript; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/configs/keybinds.json: -------------------------------------------------------------------------------- 1 | { 2 | "settings": { 3 | "name": "Settings", 4 | "description": "Open Settings window", 5 | "key": "CmdOrCtrl+," 6 | }, 7 | "open": { 8 | "name": "Open", 9 | "description": "Open a file", 10 | "key": "CmdOrCtrl+O" 11 | }, 12 | "close": { 13 | "name": "Close", 14 | "description": "Close the current file", 15 | "key": "CmdOrCtrl+W" 16 | }, 17 | "save": { 18 | "name": "Save", 19 | "description": "Save the current file", 20 | "key": "CmdOrCtrl+S" 21 | }, 22 | "save_as": { 23 | "name": "Save As", 24 | "description": "Save the current file as a new file", 25 | "key": "CmdOrCtrl+Shift+S" 26 | }, 27 | "undo": { 28 | "name": "Undo", 29 | "description": "Undo the last action", 30 | "key": "CmdOrCtrl+Z" 31 | }, 32 | "redo": { 33 | "name": "Redo", 34 | "description": "Redo the last action", 35 | "key": "CmdOrCtrl+Shift+Z" 36 | }, 37 | "cut": { 38 | "name": "Cut", 39 | "description": "Cut the selected text", 40 | "key": "CmdOrCtrl+X" 41 | }, 42 | "copy": { 43 | "name": "Copy", 44 | "description": "Copy the selected text", 45 | "key": "CmdOrCtrl+C" 46 | }, 47 | "paste": { 48 | "name": "Paste", 49 | "description": "Paste the copied text", 50 | "key": "CmdOrCtrl+V" 51 | }, 52 | "select_all": { 53 | "name": "Select All", 54 | "description": "Select all", 55 | "key": "CmdOrCtrl+A" 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/configs/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ipc": { 3 | "port": "6537" 4 | } 5 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/graph/executors/midi.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use crate::midi::MIDIFile; 3 | 4 | pub fn get_midi_file_statistics(midi_file: &MIDIFile) -> String { 5 | let track_count = midi_file.get_midi_tracks().len(); 6 | let mut seconds: f64 = 0.0; // in ms 7 | for track in midi_file.get_midi_tracks() { 8 | let final_note = track.notes.get(track.notes.len() - 1); 9 | if final_note.is_some() && final_note.unwrap().time_off > seconds { 10 | seconds = final_note.unwrap().time_off; 11 | } 12 | } 13 | 14 | let minutes = ((seconds / 60.0) % 60.0) as i32; 15 | let hours: i32 = ((seconds / 60.0) / 60.0) as i32; 16 | 17 | // build hh:mm:ss string omitting hours & minutes if they are 0 18 | let mut hhmmss = String::new(); 19 | if hours > 0 { 20 | hhmmss.push_str(&format!("{:02}:", hours)); 21 | } 22 | if minutes > 0 { 23 | hhmmss.push_str(&format!("{:02}:", minutes)); 24 | } 25 | hhmmss.push_str(&format!("{:02}", (seconds % 60.0) as i32)); 26 | 27 | // add "seconds" label if there are no hours or minutes 28 | if hours == 0 && minutes == 0 { 29 | hhmmss.push_str(" seconds"); 30 | } else { 31 | hhmmss.push_str(" minutes"); 32 | } 33 | 34 | // get track count 35 | return format!("{} tracks\n{}", track_count, hhmmss).to_string() 36 | } 37 | 38 | 39 | /// Node: get_midi_file 40 | /// 41 | /// inputs: 42 | /// "file_path": `String` 43 | /// 44 | /// outputs: 45 | /// "tracks": `Array`, 46 | /// "stats": `String` 47 | #[tauri::command] 48 | #[node_registry::node] 49 | pub fn get_midi_file(inputs: HashMap) -> HashMap { 50 | let mut outputs: HashMap = HashMap::new(); 51 | if !inputs.contains_key("file_path") { 52 | outputs.insert("tracks".to_string(), serde_json::Value::Array(vec![])); 53 | outputs.insert("stats".to_string(), serde_json::Value::String("".to_string())); 54 | return outputs; 55 | } 56 | 57 | let midi_file = MIDIFile::new(inputs["file_path"].to_string().as_str()).unwrap(); 58 | let midi_file_statistics = get_midi_file_statistics(&midi_file); 59 | outputs.insert("tracks".to_string(), serde_json::to_value(midi_file.get_midi_tracks()).unwrap()); 60 | outputs.insert("stats".to_string(), serde_json::to_value(midi_file_statistics).unwrap()); 61 | return outputs; 62 | } 63 | 64 | /// Node: get_midi_track_data 65 | /// 66 | /// inputs: 67 | /// "tracks": `Array`, 68 | /// "track_name": `String` 69 | /// 70 | /// outputs: 71 | /// "notes": `Array`, 72 | /// "control_change": `HashMap>`, 73 | /// "pitchwheel": `Array`, 74 | /// "aftertouch": `Array` 75 | #[tauri::command] 76 | #[node_registry::node] 77 | pub fn get_midi_track_data(inputs: HashMap) -> HashMap { 78 | 79 | let mut outputs: HashMap = HashMap::new(); 80 | 81 | if !inputs.contains_key("tracks") || !inputs.contains_key("track_name") { 82 | // empty array, no data 83 | outputs.insert("track".to_string(), serde_json::Value::Object(serde_json::Map::new())); 84 | return outputs; 85 | } 86 | 87 | let tracks_unwrapped = inputs["tracks"].as_array().expect("in get_midi_track, tracks is not an array").clone(); 88 | 89 | for track in tracks_unwrapped { 90 | let track_object = track.as_object().unwrap(); 91 | if track_object.get_key_value("name").unwrap().1.as_str().unwrap() == inputs["track_name"].as_str().unwrap() { 92 | outputs.insert("notes".to_string(), track_object.get("notes").unwrap().clone()); 93 | outputs.insert("control_change".to_string(), track_object.get("control_change").unwrap().clone()); 94 | outputs.insert("pitchwheel".to_string(), track_object.get("pitchwheel").unwrap().clone()); 95 | outputs.insert("aftertouch".to_string(), track_object.get("aftertouch").unwrap().clone()); 96 | break; 97 | } 98 | } 99 | 100 | return outputs; 101 | } 102 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/graph/executors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod midi; 2 | pub mod utils; 3 | pub mod scene; 4 | pub mod animation; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/graph/executors/scene.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::state::STATE; 4 | 5 | // Node: scene_link 6 | /// 7 | /// inputs: 8 | /// none 9 | /// 10 | /// outputs: 11 | /// "name": `String` 12 | /// "object_groups": `Array` 13 | #[tauri::command] 14 | #[node_registry::node] 15 | pub fn scene_link(_inputs: HashMap) -> HashMap { 16 | let mut outputs: HashMap = HashMap::new(); 17 | 18 | let state = STATE.lock().unwrap(); 19 | 20 | if !state.scene_data.contains_key("Scene") { 21 | println!("NO SCENE DATA"); 22 | outputs.insert("name".to_string(), serde_json::Value::String("".to_string())); 23 | outputs.insert("object_groups".to_string(), serde_json::Value::Array(vec![])); 24 | return outputs; 25 | } 26 | 27 | let scene = state.scene_data["Scene"].clone(); 28 | drop(state); 29 | 30 | outputs.insert("name".to_string(), serde_json::to_value(scene.name).unwrap()); 31 | outputs.insert("object_groups".to_string(), serde_json::to_value(scene.object_groups).unwrap()); 32 | return outputs; 33 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/graph/executors/utils.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | /// Node: viewer 3 | /// 4 | /// inputs: 5 | /// "data": `Any` 6 | /// 7 | /// outputs: 8 | /// None 9 | #[tauri::command] 10 | #[node_registry::node] 11 | pub fn viewer(_inputs: HashMap) { 12 | // :) 13 | return HashMap::new(); 14 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/graph/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod executors; 2 | pub mod execute; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/ipc/ipc.md: -------------------------------------------------------------------------------- 1 | # IPC Python Files 2 | 3 | ## Components 4 | ### Rust Module: `ipc` 5 | 6 | The ipc module in Rust handles the communication with Blender. Messages are exchanged in JSON format, structured with the following parameters: 7 | 8 | - `sender`: Identifies the sender of the message. 9 | - `message`: Contains the data or command to be executed. 10 | - `uuid`: A unique identifier for the message. 11 | 12 | ### Message Structure 13 | Messages sent via the `ipc` module under the hood follow this JSON format: 14 | 15 | ```json 16 | { 17 | "sender": "example_sender", 18 | "message": "your_message_data_here", 19 | "uuid": "unique_message_identifier" 20 | } 21 | ``` 22 | 23 | ### Sending a Message 24 | 25 | To send a message from Rust and receive the result, use the `ipc::send_message` function. This function is asynchronous, so it must be awaited. Here is a demonstration: 26 | 27 | 28 | ```rs 29 | use MIDIAnimator::structures::ipc; 30 | 31 | static PYTHON_FILE: &str = include_str!("some_python_file.py"); 32 | 33 | #[tokio::main] 34 | async fn main() { 35 | let result = match ipc::send_message(PYTHON_FILE.to_string()).await { 36 | Some(data) => data, 37 | None => { 38 | panic!("Error executing"); 39 | } 40 | }; 41 | 42 | println!("Result from Blender: {:?}", result); 43 | } 44 | ``` 45 | 46 | ## Python Scripts 47 | 48 | Python scripts are used to interact with Blender's Python API (bpy). These scripts are executed within Blender's environment and should follow a specific structure. 49 | 50 | ### Python Script Requirements 51 | 52 | Python scripts should define an `execute()` function, which **must** return a value. There are no specific type requirements for the return value, but it must not be `None`. Scripts can include additional functions, classes, and module imports as needed. The `bpy` module is pre-imported, but can be re-imported if necessary. 53 | 54 | Example Python script: 55 | 56 | 57 | ```py 58 | import bpy 59 | 60 | def additional_function(): 61 | pass 62 | 63 | def execute(): 64 | scene = bpy.context.scene 65 | return scene.name 66 | 67 | ``` 68 | 69 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(non_snake_case)] 2 | #![allow(dead_code)] 3 | 4 | pub mod blender; 5 | 6 | pub mod command; 7 | 8 | pub mod graph; 9 | 10 | pub mod ipc; 11 | 12 | pub mod midi; 13 | 14 | pub mod scene_generics; 15 | 16 | pub mod state; 17 | 18 | pub mod ui; 19 | 20 | pub mod utils; 21 | 22 | pub mod auto_commands; 23 | 24 | pub mod node_registry; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | #![allow(non_snake_case)] 4 | #![allow(dead_code)] 5 | 6 | use MIDIAnimator::ipc::start_server; 7 | use MIDIAnimator::state::{WINDOW, update_state}; 8 | use MIDIAnimator::ui::menu; 9 | 10 | use tauri::{generate_context, Manager}; 11 | #[derive(Clone, serde::Serialize)] 12 | struct Payload { 13 | message: String 14 | } 15 | 16 | #[tokio::main] 17 | async fn main() { 18 | // emit the global state to the frontend 19 | let context = generate_context!(); 20 | 21 | 22 | tauri::Builder::default() 23 | .invoke_handler(MIDIAnimator::auto_commands::get_cmds()) // AUTO GENERATED BY build.rs 24 | .setup(|app| { 25 | // update the global state with the window 26 | let window = app.get_window("main").unwrap(); 27 | *WINDOW.lock().unwrap() = Some(window); 28 | 29 | // start server and update the state 30 | tauri::async_runtime::spawn(async move { 31 | update_state(); // send update to frontend for initial state 32 | start_server(); // start the IPC server 33 | }); 34 | 35 | return Ok(()) 36 | }) 37 | .menu(menu::build_menu(&context.package_info().name)) 38 | .on_menu_event(|event| { 39 | menu::handle_menu_event(event); 40 | }) 41 | .run(context) 42 | .expect("error while launching MIDIAnimator!"); 43 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/scene_generics/mod.rs: -------------------------------------------------------------------------------- 1 | // use nalgebra::Vector3; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | 5 | #[derive(Serialize, Deserialize, Clone, Debug)] 6 | pub struct Vector3 { 7 | pub x: f32, 8 | pub y: f32, 9 | pub z: f32, 10 | } 11 | 12 | // MARK: - Scene 13 | #[derive(Serialize, Deserialize, Clone, Debug)] 14 | pub struct Scene { 15 | pub name: String, 16 | pub object_groups: Vec, 17 | } 18 | 19 | // MARK: - ObjectGroup 20 | #[derive(Serialize, Deserialize, Clone, Debug)] 21 | pub struct ObjectGroup { 22 | pub name: String, 23 | pub objects: Vec, 24 | } 25 | 26 | // MARK: - Object 27 | #[derive(Serialize, Deserialize, Clone, Debug)] 28 | pub struct Object { 29 | pub name: String, 30 | pub position: Vector3, 31 | pub rotation: Vector3, 32 | pub scale: Vector3, 33 | pub blend_shapes: BlendShapes, 34 | pub anim_curves: Vec, 35 | // pub mesh: Mesh 36 | } 37 | 38 | // MARK: - AnimCurve 39 | #[derive(Serialize, Deserialize, Clone, Debug)] 40 | pub struct AnimCurve { 41 | pub array_index: u32, 42 | pub auto_smoothing: String, 43 | pub data_path: String, 44 | pub extrapolation: String, 45 | pub keyframe_points: Vec, 46 | pub range: Vec, 47 | 48 | } 49 | 50 | // MARK: - BlendShape 51 | #[derive(Serialize, Deserialize, Clone, Debug)] 52 | pub struct BlendShapes { 53 | pub keys: Vec, 54 | pub reference: Option, 55 | } 56 | 57 | // MARK: - Keyframe 58 | #[derive(Serialize, Deserialize, Clone, Debug)] 59 | pub struct Keyframe { 60 | pub time: f32, 61 | pub value: f32, 62 | } 63 | 64 | // MARK: - KeyframePoint 65 | #[derive(Serialize, Deserialize, Clone, Debug)] 66 | pub struct KeyframePoint { 67 | pub amplitude: f32, 68 | pub back: f32, 69 | pub easing: String, 70 | pub handle_left: Vec, 71 | pub handle_left_type: String, 72 | pub handle_right: Vec, 73 | pub handle_right_type: String, 74 | pub interpolation: String, 75 | pub co: Vec, 76 | pub period: f32, 77 | } 78 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/state/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, sync::Mutex}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::sync::Arc; 4 | use lazy_static::lazy_static; 5 | 6 | use crate::scene_generics::Scene; 7 | 8 | 9 | 10 | lazy_static! { 11 | pub static ref STATE: Mutex = Mutex::new(AppState::default()); 12 | pub static ref WINDOW: Arc>> = Arc::new(Mutex::new(None)); 13 | } 14 | 15 | /// state struct for the application 16 | /// 17 | /// this is a global state that is shared between the front end and the backend. 18 | /// 19 | /// front end also has its own state, but this is the global state that is shared between the two. 20 | /// 21 | /// note: the only way to update this state is through the backend, and the front end can only read from it. 22 | /// if you wanted to change a variable, you will have to create a command in the backend that will update the state. 23 | #[derive(Serialize, Deserialize, Clone, Debug)] 24 | pub struct AppState { 25 | pub ready: bool, 26 | pub connected: bool, 27 | pub connected_application: String, 28 | pub connected_version: String, 29 | pub connected_file_name: String, 30 | pub scene_data: HashMap, 31 | pub rf_instance: HashMap, 32 | pub executed_results: HashMap, 33 | pub executed_inputs: HashMap, 34 | } 35 | 36 | impl Default for AppState { 37 | fn default() -> Self { 38 | Self { 39 | ready: false, 40 | connected: false, 41 | connected_application: "".to_string(), 42 | connected_version: "".to_string(), 43 | connected_file_name: "".to_string(), 44 | scene_data: HashMap::new(), 45 | rf_instance: HashMap::new(), 46 | executed_results: HashMap::new(), 47 | executed_inputs: HashMap::new(), 48 | } 49 | } 50 | } 51 | 52 | /// this commmand is called when the front end is loaded and ready to receive commands 53 | #[tauri::command] 54 | pub fn ready() -> AppState { 55 | println!("READY"); 56 | let mut state = STATE.lock().unwrap(); 57 | state.ready = true; 58 | return state.clone(); 59 | } 60 | 61 | /// this command can be called from the front end to update changes to the state from the front end 62 | /// you can use this by calling `window.tauri.invoke('js_update_state', {state: JSON.stringify(your_new_state_objet)})` 63 | /// also use setBackendState() in the front end to update React's state with the new state 64 | /// this function will replace the entire state, so make sure to include all the necessary data in the new state 65 | /// note you cannot add new fields to the state, only update the existing fields 66 | #[allow(unused_must_use)] 67 | #[tauri::command] 68 | pub fn js_update_state(state: String) { 69 | println!("FRONTEND STATE UPDATE"); 70 | // println!("{:#?}", state); 71 | let mut cur_state = STATE.lock().unwrap(); 72 | 73 | // re-serealize the state 74 | let new_state: AppState = serde_json::from_str(&state).unwrap_or_default(); 75 | 76 | // replace the current state with the new state 77 | std::mem::replace(&mut *cur_state, new_state); 78 | drop(cur_state); 79 | } 80 | 81 | #[tauri::command] 82 | pub fn log(message: String) { 83 | println!("{}", message); 84 | } 85 | 86 | 87 | /// sends state to front end 88 | /// emits via command "update_state" 89 | pub fn update_state() { 90 | println!("BACKEND STATE UPDATE"); 91 | loop { 92 | let state = STATE.lock().unwrap(); 93 | 94 | if state.ready { // if the app is ready, we can send the state 95 | break; 96 | } 97 | drop(state); 98 | } 99 | let state = STATE.lock().unwrap(); 100 | let window = WINDOW.lock().unwrap(); 101 | window.as_ref().unwrap().emit("update_state", state.clone()).unwrap(); 102 | drop(window); 103 | drop(state); 104 | } 105 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/state/state.md: -------------------------------------------------------------------------------- 1 | # Reading and Updating State 2 | 3 | ## Overview 4 | 5 | This document explains how to use the state system in Rust and TypeScript, specifically how to read from and write to the state, and the importance of managing state access to prevent deadlocks. 6 | 7 | ## Backend: Rust module `structures::state` 8 | 9 | To use the state system, you must import the `STATE` variable from the `structures::state` module. 10 | 11 | ### Importing the State 12 | 13 | ```rs 14 | use MIDIAnimator::structures::state::STATE; 15 | 16 | // writing: 17 | use MIDIAnimator::structures::state::{STATE, update_state}; 18 | ``` 19 | 20 | ### Reading from the State 21 | 22 | To read from the state, you need to lock the static `STATE` variable and unwrap it to get access. Once you have finished reading, you must `drop()` it to release the lock. 23 | 24 | #### Example 25 | 26 | ```rs 27 | let state = STATE.lock().unwrap(); 28 | // Read from the state as needed 29 | let connected_application = state.connected_application.clone(); 30 | drop(state); // Release the lock to prevent deadlocks 31 | ``` 32 | 33 | ### Writing to the State 34 | 35 | To write to the state, the process is similar to reading, but you must make the state mutable. After writing to the state, `drop()` the state to release the lock and call `update_state()` to apply the changes. 36 | 37 | #### Example 38 | 39 | ```rs 40 | let mut state = STATE.lock().unwrap(); 41 | state.connected_application = "blender".to_string(); 42 | state.connected_version = version; 43 | state.connected_file_name = file_name; 44 | drop(state); // Release the lock to prevent deadlocks 45 | update_state(); // MUST call update_state() to keep front & backend state synchronized 46 | ``` 47 | 48 | ## Why Drop the State? 49 | 50 | Dropping the state is crucial to prevent deadlocks. A deadlock can occur when one part of the application is accessing the state while another part is trying to acquire the state lock. By dropping the state after reading or writing, you release the lock, allowing other parts of the application to access the state without getting stuck in a deadlock. `clone()` parts of the state if you need multiple parts of the application to access state. _One at a time, please!_ 51 | 52 | ## Frontend: TypeScript /contexts/StateContext.tsx & `js_update_state` 53 | 54 | Reading state from the front end is quite simple. The entire `` component is wrapped in a ``, which provides global state across the entire application. 55 | 56 | ### Importing the State 57 | 58 | To read the state, you must import the `useStateContext` hook from `/contexts/StateContext/`. In your functional component, you must destructure the items in `useStateContext()`. 59 | 60 | #### Example 61 | 62 | ```tsx 63 | import { useStateContext } from "../contexts/StateContext"; 64 | 65 | function MyCustomComponent() { 66 | const { backendState, setBackEndState } = useStateContext(); 67 | return

{backendState}

; 68 | } 69 | ``` 70 | 71 | ### Writing to the State 72 | 73 | In order to write to the state, first, you must make your changes to a new object. For instance: 74 | 75 | ```tsx 76 | const newState = { ...backendState, ready: true }; 77 | setBackendState(newState); 78 | ``` 79 | 80 | writes the backendState to a new object. This is so we can pass the updated state to the backend, where it can be processed. 81 | 82 | In order to write to the backend, we must invoke Tauri with the custom function `js_update_state` & the single paramter `state`. 83 | 84 | #### Example 85 | 86 | ```tsx 87 | import { invoke } from "@tauri-apps/api/tauri"; 88 | 89 | invoke("js_update_state", {"state", JSON.stringify(newState)}); 90 | ``` 91 | 92 | This will send the newly updated state to the backend, and update it. It will not send a subsequent update to the front end, so you must set the state with `setBackendState()`. 93 | 94 | #### Full example 95 | 96 | ```tsx 97 | import { useCallback } from "react"; 98 | import { invoke } from "@tauri-apps/api/tauri"; 99 | import { useStateContext } from "../contexts/StateContext"; 100 | 101 | function MyCustomComponent() { 102 | const { backEndState, setBackEndState } = useStateContext(); 103 | 104 | const toggleConnected = useCallback(() => { 105 | let newState = { ...backEndState, connected: !backEndState.connected }; 106 | setBackEndState(newState); 107 | invoke("js_update_state", { state: JSON.stringify(newState) }); 108 | }, [backEndState]); 109 | 110 | return ( 111 | <> 112 |

State object: {JSON.stringify(backEndState)}

113 | 114 | 115 | ); 116 | } 117 | 118 | export default MyCustomComponent; 119 | ``` 120 | 121 | ## Word of Warning 122 | On app initalization, I am waiting for the entire App component to be rendered to send an update to the backend to retrieve the entire state object. This is the `ready` key in the state object. If your component is initalizing before the state is ready, you will get errors. You must ensure you have the updated state when `state.ready == True`. Once that is true, you are okay to modify the state. 123 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/ui/keybinds.rs: -------------------------------------------------------------------------------- 1 | use serde_json::from_str; 2 | pub fn get_keybind(file: &str, keybind: String) -> String { 3 | let json: serde_json::Value = from_str(&file).unwrap(); 4 | let x = json[keybind]["key"].clone(); 5 | return x.as_str().unwrap_or_default().to_string(); 6 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/ui/menu.rs: -------------------------------------------------------------------------------- 1 | use tauri::{CustomMenuItem, Menu, MenuEntry, WindowMenuEvent}; 2 | use crate::ui::keybinds; 3 | use crate::command::event; 4 | 5 | static KEYBINDS: &str = include_str!("../configs/keybinds.json"); 6 | 7 | pub fn build_menu(app_name: &str) -> Menu { 8 | let mut os_menu = Menu::os_default(app_name); 9 | 10 | for item in &mut os_menu.items.iter_mut() { 11 | // modify the menu items here 12 | if let MenuEntry::Submenu(submenu) = item { 13 | println!("{:?}", submenu); 14 | if submenu.title == app_name.to_string() { 15 | // index 0 is the about menu item, lets insert after that 16 | submenu.inner.items.insert(1, MenuEntry::CustomItem( 17 | CustomMenuItem::new("settings", "Settings") 18 | .accelerator(keybinds::get_keybind(KEYBINDS, "settings".to_string())) 19 | )); 20 | } 21 | } 22 | } 23 | 24 | return os_menu; 25 | } 26 | 27 | pub fn handle_menu_event(event: WindowMenuEvent) { 28 | match event.menu_item_id() { 29 | "settings" => { 30 | event::open_settings(event); 31 | }, 32 | _ => { 33 | println!("Unknown event: {}", event.menu_item_id()); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod menu; 2 | pub mod keybinds; -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/src/utils/ui.rs: -------------------------------------------------------------------------------- 1 | pub fn get_logical_size(window: &tauri::Window) -> tauri::LogicalSize { 2 | let cur_monitor: tauri::Monitor = window.current_monitor().unwrap().unwrap(); 3 | let s_factor: f64 = cur_monitor.scale_factor(); 4 | let phys_size: &tauri::PhysicalSize = cur_monitor.size(); 5 | let logical_size: tauri::LogicalSize = phys_size.to_logical(s_factor); 6 | 7 | return logical_size; 8 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/schema.json", 3 | "build": { 4 | "beforeBuildCommand": "npm run build", 5 | "beforeDevCommand": "npm run dev", 6 | "devPath": "http://localhost:3000", 7 | "distDir": "../build", 8 | "withGlobalTauri": true 9 | }, 10 | "package": { 11 | "productName": "MIDIAnimator", 12 | "version": "0.1.0" 13 | }, 14 | "tauri": { 15 | "allowlist": { 16 | "all": true, 17 | "fs": { 18 | "readFile": true, 19 | "scope": ["**", "**/*", "/**/*"] 20 | } 21 | }, 22 | "bundle": { 23 | "active": true, 24 | "category": "DeveloperTool", 25 | "copyright": "", 26 | "deb": { 27 | "depends": [] 28 | }, 29 | "externalBin": [], 30 | "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], 31 | "identifier": "com.jamesa08.midianimator", 32 | "longDescription": "", 33 | "macOS": { 34 | "entitlements": null, 35 | "exceptionDomain": "", 36 | "frameworks": [], 37 | "providerShortName": null, 38 | "signingIdentity": null 39 | }, 40 | "resources": ["src/configs/default_nodes.json"], 41 | "shortDescription": "", 42 | "targets": "all", 43 | "windows": { 44 | "certificateThumbprint": null, 45 | "digestAlgorithm": "sha256", 46 | "timestampUrl": "" 47 | } 48 | }, 49 | "security": { 50 | "csp": null 51 | }, 52 | "updater": { 53 | "active": false 54 | }, 55 | "windows": [ 56 | { 57 | "fullscreen": false, 58 | "height": 600, 59 | "resizable": true, 60 | "title": "MIDIAnimator", 61 | "width": 1100, 62 | "titleBarStyle": "Overlay", 63 | "hiddenTitle": true 64 | } 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/tests/ipc_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | // run from /src-tauri 3 | // cargo test 4 | mod tests { 5 | // use MIDIAnimator::structures::ipc; 6 | // TODO: add tests here 7 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/tests/midi_test.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | // run from /src-tauri 3 | // cargo test 4 | mod tests { 5 | use lazy_static::lazy_static; 6 | use MIDIAnimator::midi::MIDIFile; 7 | 8 | lazy_static! { 9 | static ref TYPE_0: &'static str = "./tests/test_midi_type_0_rs_4_14_24.mid"; 10 | static ref TYPE_1: &'static str = "./tests/test_midi_type_1_rs_4_14_24.mid"; 11 | } 12 | 13 | #[test] 14 | fn test_load_valid_midi_file() { 15 | let midi_file = MIDIFile::new(&TYPE_0).unwrap(); 16 | assert!(midi_file.get_midi_tracks().len() > 0); 17 | } 18 | 19 | #[test] 20 | fn test_load_invalid_midi_file() { 21 | let midi_file = MIDIFile::new(&"./tests/FILE_DOES_NOT_EXIST.mid"); 22 | assert!(midi_file.is_err()); 23 | } 24 | 25 | #[test] 26 | fn test_get_midi_tracks_type_0() { 27 | let midi_file = MIDIFile::new(&TYPE_0).unwrap(); 28 | let tracks = midi_file.get_midi_tracks(); 29 | // assert_eq!(tracks.len(), 2); // Adjust the expected number of tracks 30 | println!("Number of tracks: {}", tracks.len()); 31 | for track in tracks { 32 | println!("Track name: {}", track.name); 33 | } 34 | } 35 | 36 | #[test] 37 | fn test_get_midi_tracks_type_1() { 38 | let midi_file = MIDIFile::new(&TYPE_1).unwrap(); 39 | let tracks = midi_file.get_midi_tracks(); 40 | // assert_eq!(tracks.len(), 2); // Adjust the expected number of tracks 41 | println!("Number of tracks: {}", tracks.len()); 42 | for track in tracks { 43 | println!("Track name: {}", track.name); 44 | } 45 | } 46 | 47 | #[test] 48 | fn test_find_track_by_name_type_0() { 49 | let midi_file = MIDIFile::new(&TYPE_0).unwrap(); 50 | let track = midi_file.find_track("Classic Electric Piano"); 51 | assert!(track.is_some()); 52 | } 53 | 54 | #[test] 55 | fn test_find_track_by_name_type_1() { 56 | let midi_file = MIDIFile::new(&TYPE_1).unwrap(); 57 | let track = midi_file.find_track("Classic Electric Piano"); 58 | assert!(track.is_some()); 59 | } 60 | 61 | #[test] 62 | fn test_merge_tracks() { 63 | let midi_file = MIDIFile::new(&TYPE_1).unwrap(); 64 | let track1 = midi_file.find_track("Classic Electric Piano").unwrap(); 65 | let track2 = midi_file.find_track("Classic Electric Piano").unwrap(); 66 | let merged_track = midi_file.merge_tracks(track1, track2, Some("Merged Track")); 67 | assert_eq!(merged_track.name, "Merged Track"); 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/tests/test_midi_type_0_rs_4_14_24.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/tests/test_midi_type_0_rs_4_14_24.mid -------------------------------------------------------------------------------- /rust-impl/midianimator/src-tauri/tests/test_midi_type_1_rs_4_14_24.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src-tauri/tests/test_midi_type_1_rs_4_14_24.mid -------------------------------------------------------------------------------- /rust-impl/midianimator/src/App.tsx: -------------------------------------------------------------------------------- 1 | import MenuBar from "./components/MenuBar"; 2 | import ToolBar from "./components/ToolBar"; 3 | import Panel from "./components/Panel"; 4 | import StatusBar from "./components/StatusBar"; 5 | import { useEffect } from "react"; 6 | import { listen } from "@tauri-apps/api/event"; 7 | import { WebviewWindow } from "@tauri-apps/api/window"; 8 | import { invoke } from "@tauri-apps/api/tauri"; 9 | 10 | import { useStateContext } from "./contexts/StateContext"; 11 | import NodeGraph from "./components/NodeGraph"; 12 | 13 | function App() { 14 | const { backEndState: backEndState, setBackEndState: setBackEndState, frontEndState: frontEndState, setFrontEndState: setFrontEndState } = useStateContext(); 15 | 16 | useEffect(() => { 17 | // listner for window creation 18 | const windowEventListener = listen(`open-window`, (event: any) => { 19 | const window = new WebviewWindow(`${event.payload["title"]}`, event.payload); 20 | 21 | window.show(); 22 | }); 23 | 24 | const stateListner = listen("update_state", (event: any) => { 25 | setBackEndState(event.payload); 26 | }); 27 | 28 | const executionRunner = listen("execute_function", (event: any) => { 29 | invoke(event.payload["function"], event.payload["args"]).then((res: any) => {}); 30 | }); 31 | 32 | // tell the backend we're ready & get the initial state 33 | invoke("ready").then((res: any) => { 34 | if (res !== null) { 35 | setBackEndState(res); 36 | } 37 | }); 38 | 39 | return () => { 40 | windowEventListener.then((f) => f()); 41 | stateListner.then((f) => f()); 42 | executionRunner.then((f) => f()); 43 | }; 44 | }, []); 45 | 46 | return ( 47 |
48 |
49 | 50 | 51 |
52 |
53 | 54 |
55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 | 63 |
64 |
65 | ); 66 | } 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/blender.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src/blender.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src/collapse-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src/collapse-left.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src/collapse-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src/collapse-right.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/ConnectionLine.tsx: -------------------------------------------------------------------------------- 1 | import { getSimpleBezierPath, Position, useConnection } from "@xyflow/react"; 2 | 3 | export default ({ fromX, fromY, toX, toY }: { fromX: number; fromY: number; toX: number; toY: number }) => { 4 | const [d] = getSimpleBezierPath({ 5 | sourceX: fromX, 6 | sourceY: fromY, 7 | sourcePosition: Position.Right, 8 | targetX: toX, 9 | targetY: toY, 10 | targetPosition: Position.Left, 11 | }); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/IPCLink.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useStateContext } from "../contexts/StateContext"; 3 | import { invoke } from "@tauri-apps/api/tauri"; 4 | 5 | declare global { 6 | interface String { 7 | toProperCase(): string; 8 | } 9 | } 10 | 11 | String.prototype.toProperCase = function () { 12 | return this.replace(/\w\S*/g, function (txt) { 13 | return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(); 14 | }); 15 | }; 16 | 17 | function IPCLink() { 18 | const { backEndState: state, setBackEndState: setState } = useStateContext(); 19 | 20 | const [menuShown, setMenuShown] = useState(false); 21 | 22 | function openMenu() { 23 | setMenuShown(!menuShown); 24 | } 25 | 26 | function disconnect() { 27 | console.log("disconnect button pushed"); 28 | } 29 | 30 | function showWhenConnected() { 31 | if (state.connected) { 32 | return ( 33 | <> 34 |

{`${state.connected_application.toProperCase()} version ${state.connected_version}`}

35 |

{`${state.connected_file_name}`}

36 |

{`Port: ${state.port}`}

37 | 40 | 41 | ); 42 | } 43 | } 44 | 45 | const floatingPanel = ( 46 |
47 |

{state.connected ? "" : "Disconnected. Please connect on the 3D application to start."}

48 | {showWhenConnected()} 49 |
50 | ); 51 | 52 | return ( 53 |
54 |
55 |
56 | {state.connected ? "CONNECTED" : "DISCONNECTED"} 57 | {floatingPanel} 58 |
59 | ); 60 | } 61 | 62 | export default IPCLink; 63 | function useEffect(arg0: () => void, arg1: any[]) { 64 | throw new Error("Function not implemented."); 65 | } 66 | 67 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/MacTrafficLights.tsx: -------------------------------------------------------------------------------- 1 | function MacTrafficLights() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | } 10 | 11 | export default MacTrafficLights; 12 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | import MacTrafficLights from "./MacTrafficLights"; 2 | import Tab from "./Tab"; 3 | import IPCLink from "./IPCLink"; 4 | 5 | function MenuBar() { 6 | return ( 7 |
8 | {navigator.userAgent.includes("Mac OS") && } 9 | 10 | 11 |
12 | ); 13 | } 14 | 15 | export default MenuBar; 16 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | import nodeTypes from "../nodes/NodeTypes"; 4 | import { ReactFlowProvider } from "@xyflow/react"; 5 | import { WebviewWindow } from "@tauri-apps/api/window"; 6 | import { listen } from "@tauri-apps/api/event"; 7 | 8 | interface PanelProps { 9 | id: string; 10 | name: string; 11 | } 12 | 13 | const Panel: React.FC = ({ id, name }) => { 14 | const navigate = useNavigate(); 15 | 16 | useEffect(() => { 17 | const handleClick = (event: any) => { 18 | console.log(`Got ${JSON.stringify(event)} on window listener`); 19 | }; 20 | 21 | const setupListener = async () => { 22 | try { 23 | const unlisten = await listen("clicked", handleClick); 24 | return () => { 25 | unlisten(); 26 | }; 27 | } catch (error) { 28 | console.error("Failed to setup event listener:", error); 29 | } 30 | }; 31 | 32 | setupListener(); 33 | }, []); 34 | 35 | const createWindow = (event: React.MouseEvent) => { 36 | const webview = new WebviewWindow(id, { 37 | url: `/#/panel/${id}`, 38 | title: name, 39 | width: 400, 40 | height: 300, 41 | resizable: true, 42 | x: event.screenX, 43 | y: event.screenY, 44 | }); 45 | 46 | webview.once("tauri://created", () => { 47 | console.log("Created new window"); 48 | }); 49 | 50 | webview.once("tauri://error", (e: any) => { 51 | console.error(`Error creating new window ${e.payload}`); 52 | }); 53 | 54 | navigate(`/#/panel/${id}`); 55 | }; 56 | 57 | function windowIfNodes() { 58 | if (name == "Nodes") { 59 | return ( 60 | 61 | {Object.entries(nodeTypes).map(([key, value]) => { 62 | const Node: any = value; 63 | return ; 64 | })} 65 | 66 | ); 67 | } 68 | } 69 | 70 | return ( 71 |
72 |
73 | {name} 74 | 77 |
78 | {windowIfNodes()} 79 |
80 | ); 81 | }; 82 | 83 | export default Panel; 84 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/PanelContent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useParams } from "react-router-dom"; 3 | 4 | const PanelContent: React.FC = () => { 5 | const { id } = useParams<{ id: string }>(); 6 | 7 | console.log("PanelContent component rendered"); 8 | 9 | return ( 10 |
11 |

Hello World {id}

12 |
13 | ); 14 | }; 15 | 16 | export default PanelContent; 17 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/StatusBar.tsx: -------------------------------------------------------------------------------- 1 | function StatusBar({ event }: { event: string }) { 2 | return ( 3 |
4 |
{event}
5 |
6 | ); 7 | } 8 | 9 | export default StatusBar; 10 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | function Tab({ name }: { name: string }): JSX.Element { 2 | return ( 3 |
4 |
{name}
5 |
6 | 7 | 8 | 9 |
10 |
11 | ); 12 | } 13 | 14 | export default Tab; 15 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/Tool.tsx: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/tauri"; 2 | 3 | function Tool({ type }: { type: string }) { 4 | var icon; 5 | if (type == "run") { 6 | icon = ( 7 | 8 | 9 | 10 | ); 11 | } else if (type == "collapse-left") { 12 | icon = collapse-left; 13 | } else if (type == "collapse-right") { 14 | icon = collapse-right; 15 | } 16 | 17 | return ( 18 |
{ 21 | if (type == "run") { 22 | invoke("execute_graph", { realtime: false }); 23 | } 24 | }} 25 | > 26 | {icon} 27 |
28 | ); 29 | } 30 | 31 | export default Tool; 32 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/components/ToolBar.tsx: -------------------------------------------------------------------------------- 1 | import Tool from "./Tool"; 2 | 3 | function MenuBar() { 4 | return ( 5 |
6 | {/* logo */} 7 |
8 | logo 9 |
10 | 11 |
12 | 13 | {/* left aligned items */} 14 |
15 | 16 |
17 | 18 | {/* other icons here */} 19 | 20 | {/* right aligned items */} 21 |
22 | 23 | 24 |
25 |
26 | ); 27 | } 28 | 29 | export default MenuBar; 30 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/contexts/StateContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | 3 | export const StateContext = createContext(null); 4 | 5 | const defaultBackendState = { ready: false }; 6 | const defaultFrontendState = { panelsShown: [1, 2] }; 7 | 8 | type StateContextProviderProps = { 9 | children: React.ReactNode; 10 | }; 11 | type StateContext = { 12 | backEndState: any; 13 | setBackEndState: React.Dispatch>; 14 | frontEndState: any; 15 | setFrontEndState: React.Dispatch>; 16 | }; 17 | 18 | // create a context provider 19 | const StateContextProvider = ({ children }: StateContextProviderProps) => { 20 | const [backendState, setBackEndState] = useState(defaultBackendState); 21 | const [frontendState, setFrontEndState] = useState(defaultFrontendState); 22 | 23 | return {children}; 24 | }; 25 | 26 | // custom state hook 27 | export const useStateContext = () => { 28 | const contextObj = useContext(StateContext); 29 | 30 | if (!contextObj) { 31 | throw new Error("useStateContext must be used within a StateContextProvider"); 32 | } 33 | 34 | return contextObj; 35 | }; 36 | 37 | export default StateContextProvider; 38 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .mac-traffic-lights-container { 6 | padding-left: 10px; 7 | padding-right: 10px; 8 | align-items: center; 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: left; 12 | gap: 8px; 13 | } 14 | 15 | .mac-traffic-light { 16 | width: 12px; 17 | height: 12px; 18 | border-radius: 50%; 19 | display: inline-block; 20 | } 21 | 22 | /* FIXME eventually remove mac traffic lights */ 23 | .mac-traffic-lights-container div { 24 | background: transparent!important; 25 | } 26 | 27 | .red { 28 | background-color: #ff5f57; 29 | } 30 | 31 | .yellow { 32 | background-color: #febc2e; 33 | } 34 | 35 | .green { 36 | background-color: #28c840; 37 | } 38 | 39 | .helvetica { 40 | font-family: 'Helvetica', sans-serif; 41 | } 42 | 43 | .blender { 44 | background: url(blender.png); 45 | background-size: contain; 46 | background-repeat: no-repeat; 47 | } 48 | 49 | /* prevent overscroll bounce */ 50 | body { 51 | overflow: hidden; 52 | } 53 | 54 | #gtx-trans { 55 | display: none; 56 | } 57 | 58 | img { 59 | user-select: none; 60 | pointer-events: none; 61 | } 62 | 63 | /* sorry */ 64 | .react-flow__attribution { 65 | display: none; 66 | } 67 | 68 | .node { 69 | border-radius: 6px; 70 | box-shadow: 0 1px 4px rgba(0,0,0,0.2); 71 | /* background: #303030; */ 72 | min-width: 200px; 73 | max-width: 1000px; 74 | background: #fcfcfc; 75 | border: 1px solid #0f1010; 76 | color: black; 77 | } 78 | 79 | .node.preview { 80 | /* enable dragging preview components */ 81 | cursor: grab; 82 | position: relative; 83 | 84 | /* scale temporary for now, will be managed by JS later */ 85 | transform: scale(0.5); 86 | transform-origin: top left; 87 | } 88 | 89 | /* all preview components inside should not be able to be selected. This is to "disable them" essentially */ 90 | .node.preview * { 91 | user-select: none; 92 | pointer-events: none; 93 | } 94 | 95 | .node { 96 | font-size: 12px; 97 | } 98 | 99 | .node .node-inner { 100 | margin: 2px 0px 4px; 101 | } 102 | 103 | .node .node-field { 104 | position: relative; 105 | margin: 2px 12px; 106 | } 107 | 108 | .node-header { 109 | color: #fff; 110 | padding: 4px 8px; 111 | border-top-left-radius: 5px; 112 | border-top-right-radius: 5px; 113 | box-shadow: inset 0 -1 rgba(0,0,0,0.4); 114 | } 115 | 116 | .react-flow__node.selected .node { 117 | border: 1px solid rgb(29, 198, 255); 118 | -webkit-box-sizing: border-box!important; 119 | box-sizing: border-box!important; 120 | } 121 | 122 | .react-flow__pane.draggable { 123 | cursor: default; 124 | } 125 | 126 | .react-flow__resize-control.line.right { 127 | border-right-width: 8px; 128 | border-right-color: #ffffff00; 129 | } -------------------------------------------------------------------------------- /rust-impl/midianimator/src/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesa08/MIDIAnimator/8463ea5b1931311f0d30803ccd992111a32ca9c7/rust-impl/midianimator/src/logo.png -------------------------------------------------------------------------------- /rust-impl/midianimator/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import { HashRouter as Router, Route, Routes } from "react-router-dom"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | import "./index.css"; 7 | import PanelContent from "./components/PanelContent"; 8 | import Settings from "./windows/Settings"; 9 | import StateContextProvider from "./contexts/StateContext"; 10 | 11 | const rootElement = document.getElementById("root"); 12 | 13 | if (rootElement) { 14 | const root = ReactDOM.createRoot(rootElement); 15 | root.render( 16 | 17 | 18 | 19 | 20 | } /> 21 | } /> 22 | } /> 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | // If you want to start measuring performance in your app, pass a function 31 | // to log results (for example: reportWebVitals(console.log)) 32 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 33 | reportWebVitals(console.log); 34 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/nodes/BaseNode.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import React, { ReactNode, useCallback, useState, useEffect } from "react"; 3 | import { Handle, NodeResizeControl, Position } from "@xyflow/react"; 4 | import "@xyflow/react/dist/base.css"; 5 | import NodeHeader from "./NodeHeader"; 6 | import { memo } from "react"; 7 | import { useDimensions } from "../hooks/useDimensions"; 8 | 9 | const handleStyle = { 10 | width: "16px", 11 | height: "16px", 12 | display: "flex", 13 | justifyContent: "center", 14 | alignItems: "center", 15 | position: "absolute", 16 | }; 17 | 18 | /// base node for creating nodes 19 | /// @param nodeData: the data for the node (NOT reactflow data) 20 | /// @param inject: map of handles with ui elements to inject into the handle 21 | /// @param hidden: map of handles to hide, good for when you want to hide a handle but want to write data to it (ui element) 22 | /// @param executor: function to execute when the node is executed. only should be used for nodes that use JS execution 23 | /// @param dynamicHandles: map of handles to add to the node. looks exactly like handles found in `default_nodes.json`. good for when you want to add handles to a node that are not in the node data, dynamically as a UI feature 24 | /// @param data: reactflow data 25 | /// @param children: may be removed later 26 | function BaseNode({ nodeData, inject, hidden, executor, dynamicHandles, data, children }: { nodeData: any; inject?: any; executor?: any; hidden?: any; dynamicHandles?: any; data: any; children?: ReactNode }) { 27 | // iterate over handles 28 | let handleObjects = []; 29 | 30 | let preview = data != undefined && data == "preview" ? true : false; 31 | 32 | if (nodeData != null) { 33 | const handleTypes = ["outputs", "inputs"]; 34 | for (let handleType of handleTypes) { 35 | let rfHandleType: boolean = false; 36 | if (handleType == "inputs") { 37 | rfHandleType = true; 38 | } 39 | 40 | let dynHandleArray = dynamicHandles == null || dynamicHandles[handleType] == undefined ? [] : dynamicHandles[handleType]; 41 | 42 | for (let handle of [...nodeData["handles"][handleType], ...dynHandleArray]) { 43 | let uiInject = <>; 44 | let uiHidden = false; 45 | 46 | if (inject != null && inject[handle["id"]] != null) { 47 | uiInject = inject[handle["id"]]; 48 | } 49 | 50 | if (hidden != null && hidden[handle["id"]] != null) { 51 | uiHidden = hidden[handle["id"]]; 52 | } 53 | 54 | const buildHandle = ( 55 | <> 56 |
57 | {handle["name"]} 58 | {preview ? <> : } 59 |
60 | {uiInject} 61 | 62 | ); 63 | handleObjects.push(buildHandle); 64 | } 65 | } 66 | } 67 | 68 | return ( 69 |
70 | 71 | 72 |
{handleObjects.map((handle) => handle)}
73 |
74 | ); 75 | } 76 | 77 | export default memo(BaseNode); 78 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/nodes/NodeHeader.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import * as st from "../styles.tsx"; 3 | 4 | function NodeHeader({ label, type }: {label: any, type: any}) { 5 | return ( 6 |
13 | {label} 14 |
15 | ); 16 | } 17 | 18 | export default NodeHeader; 19 | 20 | 21 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/nodes/NodeTypes.tsx: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | type NodeComponentModule = { 3 | default: React.ComponentType; 4 | }; 5 | 6 | const nodeComponents: Record = import.meta.glob("./*.tsx", { eager: true }); 7 | 8 | // Function to convert file names to node names 9 | function convertFileNameToNodeName(fileName: string) { 10 | return fileName 11 | .replace("./", "") // remove relative path 12 | .replace(".tsx", "") // remove file extension 13 | .replace(/([A-Z])/g, "_$1") // convert camelcase to snake_case 14 | .toLowerCase(); // convert to lowercase 15 | } 16 | 17 | let nodeTypes: any = {}; 18 | for (const [filePath, componentModule] of Object.entries(nodeComponents)) { 19 | const key = convertFileNameToNodeName(filePath); // convert file name to node name 20 | if (key[0] == "_") { 21 | continue; 22 | } // skip files that are not nodes 23 | nodeTypes[key] = componentModule.default; // get default export from module 24 | } 25 | 26 | export default nodeTypes; 27 | -------------------------------------------------------------------------------- /rust-impl/midianimator/src/nodes/animation_generator.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { message, open } from "@tauri-apps/api/dialog"; 3 | import "@xyflow/react/dist/base.css"; 4 | import BaseNode from "./BaseNode"; 5 | import { useStateContext } from "../contexts/StateContext"; 6 | import { getNodeData } from "../utils/node"; 7 | import { useReactFlow } from "@xyflow/react"; 8 | import { invoke } from "@tauri-apps/api/tauri"; 9 | 10 | function animation_generator({ id, data, isConnectable }: { id: any; data: any; isConnectable: any }) { 11 | const { updateNodeData } = useReactFlow(); 12 | const { backEndState: state, setBackEndState: setState } = useStateContext(); 13 | 14 | const [nodeData, setNodeData] = useState(null); 15 | // const [file, setFile] = useState(""); 16 | // const fileName = file.split("/").pop(); 17 | 18 | // const [fileStatsState, setFileStatsState] = useState({ tracks: 0, minutes: "0:00" }); 19 | 20 | // useEffect(() => { 21 | // var fileStats: any = {}; 22 | // if (state != undefined && state.executed_results != undefined && id != undefined && id in state.executed_results) { 23 | // for (let line of state.executed_results[id]["stats"].split("\n")) { 24 | // let res = line.split(" "); 25 | // if (res[1] == "tracks") { 26 | // fileStats["tracks"] = res[0]; 27 | // } else if (res[1] == "minutes" || res[1] == "seconds") { 28 | // fileStats["minutes"] = line; 29 | // } 30 | // } 31 | // setFileStatsState(fileStats); 32 | // } 33 | // }, [state.executed_results]); 34 | 35 | useEffect(() => { 36 | getNodeData("animation_generator").then(setNodeData); 37 | }, []); 38 | 39 | // const pick = useCallback(async () => { 40 | // let res = await onMIDIFilePick(); 41 | // if (res != null) { 42 | // console.log("updating data{} object"); 43 | // setFile(res.toString()); 44 | // updateNodeData(id, { ...data, inputs: { ...data.inputs, file_path: res.toString() } }); 45 | // } 46 | // }, []); 47 | 48 | // const filePathComponent = ( 49 | // <> 50 | // 53 | //
{fileName}
54 | 55 | // {Object.keys(fileStatsState).length != 0 && fileStatsState.tracks != 0 ? ( 56 | // <> 57 | //
58 | // {fileStatsState.tracks} track{fileStatsState.tracks == 1 ? "" : "s"} 59 | //
60 | //
{fileStatsState.minutes}
61 | // 62 | // ) : ( 63 | // <> 64 | // )} 65 | // 66 | // ); 67 | 68 | const uiInject = { 69 | // file_path: filePathComponent, 70 | }; 71 | 72 | const hiddenHandles = { 73 | // file_path: true, 74 | }; 75 | 76 | return