├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── LICENSE ├── README.md ├── data ├── assets │ ├── gm1_sound_set.png │ ├── gm1_system.png │ ├── gm2.svg │ ├── gml.png │ ├── midiexplorer_logo.svg │ └── sources │ │ ├── MIDI_connector2.svg │ │ ├── Steering_Wheel_Black.svg │ │ └── gplv3-or-later.svg └── screenshots │ ├── GUIhistoryleft.png │ ├── GUIimprovedprobedecoding.png │ ├── GUIprototype.png │ ├── GUIstaticdecoding.png │ ├── GUIwithcontrollersandnotes.png │ ├── GUIwithgenerator.png │ ├── GUIwithinputmonitor.png │ ├── GUIwithmonitortitles.png │ ├── GUIwithprobedata.png │ ├── GUIwithsmfdecodingprototype.png │ └── GUIwithtypemonitor.png ├── pyproject.toml └── src └── midiexplorer ├── __about__.py ├── __config__.py ├── __init__.py ├── __main__.py ├── fonts ├── Roboto-Regular.ttf ├── RobotoMono-Regular.ttf └── __init__.py ├── gui ├── __init__.py ├── helpers │ ├── __init__.py │ ├── callbacks │ │ ├── __init__.py │ │ ├── _sample.py │ │ └── debugging.py │ ├── config.py │ ├── constants │ │ ├── __init__.py │ │ └── slots.py │ ├── convert.py │ ├── logger.py │ ├── menu.py │ ├── probe.py │ └── smf.py └── windows │ ├── __init__.py │ ├── about.py │ ├── conn.py │ ├── gen.py │ ├── hist │ ├── __init__.py │ └── data.py │ ├── log.py │ ├── mon │ ├── __init__.py │ ├── blink.py │ ├── data.py │ └── settings.py │ └── smf.py ├── icons ├── __init__.py ├── gplv3-or-later-sm.png ├── gplv3-or-later.png ├── midiexplorer.ico ├── midiexplorer.svg ├── midiexplorer_128.png ├── midiexplorer_16.png ├── midiexplorer_256.png ├── midiexplorer_32.png ├── midiexplorer_48.png ├── midiexplorer_512.png ├── midiexplorer_64.png └── midiexplorer_96.png └── midi ├── MIDI Implementation Chart v1.0 Sample.rst ├── MIDI Implementation Chart v2.0 Sample.rst ├── __init__.py ├── decoders ├── __init__.py └── sysex.py ├── mido2standard.py ├── notes.py ├── ports.py └── timestamp.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rdoursenaud # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | midiexplorer.ini 4 | /.cache/ 5 | /.coverage* 6 | /.github/ 7 | /.pytest_cache/ 8 | /.run/ 9 | /build/ 10 | /dist/ 11 | /midiexplorer.build/ 12 | /midiexplorer.dist/ 13 | /midiexplorer.onefile-build/ 14 | /midiexplorer.cmd 15 | /midiexplorer.exe 16 | /RELEASE.md 17 | /STYLE.md 18 | /TODO.md 19 | -------------------------------------------------------------------------------- /data/assets/gm1_sound_set.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/assets/gm1_sound_set.png -------------------------------------------------------------------------------- /data/assets/gm1_system.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/assets/gm1_system.png -------------------------------------------------------------------------------- /data/assets/gm2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 51 | 56 | 170 | -------------------------------------------------------------------------------- /data/assets/gml.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/assets/gml.png -------------------------------------------------------------------------------- /data/assets/midiexplorer_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MIDI Explorer Logo 23 | 45 | 47 | 85 | 133 | 141 | MIDI Explorer Logo2022-07-12Raphaël DoursenaudCreative Commons Attribution-Share Alike 4.0 Internationalhttps://commons.wikimedia.org/wiki/File:MIDI_connector2.svg, https://commons.wikimedia.org/wiki/File:Steering_Wheel_Black.svghttps://commons.wikimedia.org/wiki/User:Fred_the_Oyster 145 | https://commons.wikimedia.org/wiki/User:SpiderLogo for https://github.com/ematech/midiexplorer 148 | 150 | 152 | 154 | 156 | 158 | -------------------------------------------------------------------------------- /data/assets/sources/MIDI_connector2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 3 24 | 5 25 | 2 26 | 4 27 | 1 28 | 29 | 30 | -------------------------------------------------------------------------------- /data/assets/sources/Steering_Wheel_Black.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 14 | 16 | 17 | 19 | image/svg+xml 20 | 22 | 23 | 24 | 25 | 26 | 29 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /data/screenshots/GUIhistoryleft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIhistoryleft.png -------------------------------------------------------------------------------- /data/screenshots/GUIimprovedprobedecoding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIimprovedprobedecoding.png -------------------------------------------------------------------------------- /data/screenshots/GUIprototype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIprototype.png -------------------------------------------------------------------------------- /data/screenshots/GUIstaticdecoding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIstaticdecoding.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithcontrollersandnotes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithcontrollersandnotes.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithgenerator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithgenerator.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithinputmonitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithinputmonitor.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithmonitortitles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithmonitortitles.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithprobedata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithprobedata.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithsmfdecodingprototype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithsmfdecodingprototype.png -------------------------------------------------------------------------------- /data/screenshots/GUIwithtypemonitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/data/screenshots/GUIwithtypemonitor.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | # https://packaging.python.org 8 | 9 | [build-system] 10 | requires = ['hatchling'] 11 | build-backend = 'hatchling.build' 12 | 13 | [project] # https://packaging.python.org/en/latest/specifications/declaring-project-metadata 14 | name = "midiexplorer" # https://peps.python.org/pep-0503/ 15 | description = "Yet another MIDI monitor, analyzer, debugger and manipulation tool." 16 | readme = 'README.md' 17 | requires-python = '>=3.10' 18 | # https://peps.python.org/pep-0508 19 | # https://peps.python.org/pep-0440/#version-specifiers 20 | dependencies = [ 21 | 'dearpygui~=2.0.0', 22 | 'dearpygui-ext~=2.0.0', 23 | 'midi_const~=0.1.2', 24 | 'mido~=1.3.0', # FIXME: currently using custom 1.2.11a1 with EOX, running status and delta time support 25 | 'python-rtmidi~=1.5.5', # While it's mido's default backend, we explicitly require it for some features. 26 | 'pillow~=11.0.0', 27 | ] 28 | license = { file = 'LICENSE' } 29 | authors = [ 30 | { name = "Raphaël Doursenaud", email = 'rdoursenaud@free.fr' } 31 | ] 32 | keywords = [ 33 | "MIDI", 34 | "Analyzer", 35 | "Debugger", 36 | ] 37 | classifiers = [ 38 | 'Development Status :: 3 - Alpha', 39 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 40 | 'Environment :: Win32 (MS Windows)', 41 | 'Environment :: MacOS X', 42 | 'Environment :: X11 Applications', 43 | # 'Environment :: Plugins', # TODO: module plugins 44 | 'Intended Audience :: Customer Service', 45 | 'Intended Audience :: Developers', 46 | 'Intended Audience :: Education', 47 | 'Intended Audience :: End Users/Desktop', 48 | 'Intended Audience :: Information Technology', 49 | 'Intended Audience :: Manufacturing', 50 | 'Intended Audience :: Other Audience', 51 | 'Intended Audience :: Science/Research', 52 | 'Intended Audience :: System Administrators', 53 | 'Intended Audience :: Telecommunications Industry', 54 | 'Natural Language :: English', 55 | # 'Operating System :: Microsoft :: Windows :: Windows 8.1', # TODO: test 56 | 'Operating System :: Microsoft :: Windows :: Windows 10', 57 | 'Operating System :: Microsoft :: Windows :: Windows 11', 58 | 'Operating System :: MacOS :: MacOS X', 59 | 'Operating System :: POSIX :: Linux', 60 | 'Programming Language :: Python :: 3', 61 | 'Programming Language :: Python :: 3 :: Only', 62 | 'Programming Language :: Python :: 3.10', 63 | 'Programming Language :: Python :: 3.11', 64 | 'Programming Language :: Python :: 3.12', 65 | 'Programming Language :: Python :: 3.13', 66 | 'Topic :: Artistic Software', 67 | 'Topic :: Communications', 68 | # 'Topic :: Documentation :: Sphinx', # TODO 69 | 'Topic :: Education :: Testing', 70 | 'Topic :: Home Automation', 71 | # 'Topic :: Internet', # TODO: implement RTP-MIDI support 72 | 'Topic :: Multimedia :: Sound/Audio :: Analysis', 73 | 'Topic :: Multimedia :: Sound/Audio :: MIDI', 74 | # 'Topic :: Multimedia :: Sound/Audio :: Players', # TODO: implement SMF player 75 | 'Topic :: Scientific/Engineering', 76 | 'Topic :: Scientific/Engineering :: Human Machine Interfaces', 77 | 'Topic :: Scientific/Engineering :: Information Analysis', 78 | 'Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator', 79 | 'Topic :: Scientific/Engineering :: Visualization', 80 | 'Topic :: Software Development :: Debuggers', 81 | 'Topic :: Software Development :: Embedded Systems', 82 | 'Topic :: Software Development :: Quality Assurance', 83 | 'Topic :: Software Development :: Testing', 84 | 'Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Audio', 85 | # 'Topic :: System :: Benchmark', # TODO: implement latency trip tester and timegraphs 86 | # 'Topic :: System :: Emulators', # TODO: implement sending messages and extended protocol emulators 87 | # 'Topic :: System :: Hardware :: Hardware Drivers', # TODO: implement direct communication 88 | # 'Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Audio', # TODO: implement direct USB MIDI communication 89 | # 'Topic :: System :: Hardware :: Universal Serial Bus (USB) :: Diagnostic Device', # TODO: implement hardware probe support 90 | # 'Topic :: System :: Logging', # TODO: log to file 91 | 'Topic :: System :: Monitoring', 92 | 'Topic :: Utilities', 93 | 'Typing :: Typed', 94 | ] # https://pypi.org/classifiers/ 95 | dynamic = [ 96 | 'version', 97 | ] 98 | 99 | [project.urls] 100 | Homepage = 'https://github.com/ematech/midiexplorer' 101 | Issues = 'https://github.com/ematech/midiexplorer/issues' 102 | 103 | [project.gui-scripts] 104 | midiexplorer = 'midiexplorer.__main__:main' 105 | 106 | [tool.hatch.version] 107 | path = 'src/midiexplorer/__about__.py' 108 | 109 | [tool.hatch.envs.default] 110 | dependencies = [ 111 | 'pylint', 112 | 'darglint2', 113 | 'pytest', 114 | 'pytest-cov', 115 | ] 116 | [tool.hatch.envs.default.scripts] 117 | srclint = 'pylint src/' 118 | doclint = 'darglint2 -s sphinx src/' 119 | lint = 'pylint && doclint' 120 | cov = 'pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=hatch_demo --cov=tests' 121 | no-cov = 'cov --no-cov' 122 | 123 | [[tool.hatch.envs.test.matrix]] 124 | python = [ 125 | '310', 126 | '311', 127 | '312', 128 | '313', 129 | ] 130 | 131 | [tool.hatch.envs.docs] 132 | dependencies = [ 133 | 'sphinx', 134 | ] 135 | 136 | [tool.coverage.run] 137 | branch = true 138 | parallel = true 139 | omit = [ 140 | 'src/midiexplorer/__about__.py', 141 | ] 142 | 143 | [tool.coverage.report] 144 | exclude_lines = [ 145 | 'no cov', 146 | 'if __name__ == .__main__.:', 147 | 'if TYPE_CHECKING:', 148 | ] 149 | 150 | [tool.hatch.build.targets.sdist] 151 | exclude = [ 152 | '/.cache', 153 | '/.pytest_cache', 154 | '/.run', 155 | '/.gitignore', 156 | '/data', 157 | '/dist', 158 | '/docs', 159 | '/midiexplorer.build', 160 | '/.coverage', 161 | '/.gitignore', 162 | '/.midiexplorer.cmd', 163 | '/.midiexplorer.exe', 164 | '/RELEASE.md', 165 | '/STYLE.md', 166 | '/TODO.md', 167 | ] 168 | 169 | [tool.hatch.build.targets.wheel] 170 | packages = [ 171 | 'src/midiexplorer', 172 | ] 173 | -------------------------------------------------------------------------------- /src/midiexplorer/__about__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | MIDI Explorer metadata. 9 | """ 10 | 11 | __version__ = '0.0.1a5' # https://peps.python.org/pep-0440/ 12 | -------------------------------------------------------------------------------- /src/midiexplorer/__config__.py: -------------------------------------------------------------------------------- 1 | # 2 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | """ 7 | Midi Explorer configuration. 8 | """ 9 | 10 | INIT_FILENAME = "midiexplorer.ini" 11 | DEBUG = False # TODO: allow changing with CLI parameter to the main app 12 | -------------------------------------------------------------------------------- /src/midiexplorer/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | APPLICATION_NAME = "MIDI Explorer" 8 | -------------------------------------------------------------------------------- /src/midiexplorer/__main__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | MIDI Explorer main program. 9 | """ 10 | 11 | import dearpygui.dearpygui as dpg # https://dearpygui.readthedocs.io/en/latest/ 12 | 13 | import midiexplorer.gui 14 | from midiexplorer import APPLICATION_NAME 15 | from midiexplorer.__config__ import INIT_FILENAME 16 | from midiexplorer.gui.helpers.logger import Logger 17 | from midiexplorer.midi.ports import midi_in_queue 18 | from midiexplorer.midi.timestamp import Timestamp 19 | 20 | 21 | def main() -> None: 22 | """Entry point and main loop. 23 | 24 | """ 25 | Timestamp() # Initializes start time ASAP 26 | # Use logger cache since we can’t instantiate the logger window yet 27 | Logger.log(f"Application started at {Timestamp().START_TIME}") 28 | 29 | dpg.create_context() 30 | Logger.log("DPG Context created") 31 | 32 | # TODO: determine which init file to load (Vendor or user) 33 | 34 | # --------------------- 35 | # Initial configuration 36 | # 37 | # Must be done before creating the viewport 38 | # --------------------- 39 | Logger.log(f"Configuring app using init file: {INIT_FILENAME}") 40 | dpg.configure_app( 41 | # FIXME: upstream documentation is misleading. 42 | #load_init_file=INIT_FILENAME, # Non-modifiable vendor init file 43 | docking=True, 44 | docking_space=True, 45 | docking_shift_only=False, 46 | #init_file=INIT_FILENAME, # Init file modifiable by user 47 | auto_save_init_file=False, # Saves window positions on close 48 | # FIXME: determine what these do! 49 | # Upstream: document 50 | #device: int # GPU selection 51 | #auto_device: bool # GPU selection 52 | #allow_alias_overwrites: bool 53 | #manual_alias_management: bool 54 | #skip_keyword_args: bool 55 | #skip_positional_args: bool 56 | #skip_required_args: bool 57 | #wait_for_input=False, # Only update on user input 58 | #manual_callback_management: bool 59 | #keyboard_navigation=False, # Accessibility 60 | #anti_aliased_lines=True, 61 | #anti_aliased_lines_use_tex=True, 62 | #anti_aliased_fill=True, 63 | ) 64 | 65 | midiexplorer.gui.init() 66 | 67 | # Normal logger is now initialized and available 68 | logger = Logger() 69 | logger.log(f"{APPLICATION_NAME} GUI initialized") 70 | 71 | # --------- 72 | # MAIN LOOP 73 | # --------- 74 | while dpg.is_dearpygui_running(): # Replaces dpg.start_dearpygui() 75 | # TODO: Use a generic event handler with subscribe pattern instead? 76 | 77 | # Retrieve MIDI inputs data if not using a callback 78 | if dpg.get_value('input_mode') == 'Polling': 79 | midiexplorer.gui.windows.conn.poll_processing() 80 | 81 | # Process MIDI inputs data 82 | while not midi_in_queue.empty(): 83 | midiexplorer.gui.windows.conn.handle_received_data(*midi_in_queue.get()) 84 | 85 | # Update monitor visual cues 86 | midiexplorer.gui.windows.mon.blink.update_mon_status() 87 | 88 | # Render DPG frame 89 | dpg.render_dearpygui_frame() 90 | 91 | dpg.destroy_context() 92 | 93 | 94 | if __name__ == '__main__': 95 | main() 96 | -------------------------------------------------------------------------------- /src/midiexplorer/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /src/midiexplorer/fonts/RobotoMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/fonts/RobotoMono-Regular.ttf -------------------------------------------------------------------------------- /src/midiexplorer/fonts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/fonts/__init__.py -------------------------------------------------------------------------------- /src/midiexplorer/gui/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | GUI elements (DearPy GUI). 9 | """ 10 | import importlib.resources 11 | from typing import Any, Optional 12 | 13 | from dearpygui import dearpygui as dpg 14 | from dearpygui_ext.logger import mvLogger 15 | 16 | import midiexplorer.fonts 17 | import midiexplorer.icons 18 | import midiexplorer.midi 19 | from midiexplorer.__config__ import DEBUG 20 | from midiexplorer.gui.helpers import constants, logger, menu 21 | from midiexplorer.gui.helpers.logger import Logger 22 | from midiexplorer.gui.windows import conn, gen, hist, mon, smf 23 | from midiexplorer.gui.helpers.callbacks.debugging import ( 24 | enable as enable_dpg_cb_debugging 25 | ) 26 | 27 | 28 | def init(): 29 | """Initializes the GUI. 30 | 31 | """ 32 | 33 | # -------- 34 | # Viewport 35 | # -------- 36 | Logger.log("Creating viewport") 37 | # FIXME: compute dynamically? 38 | vp_width = 1920 39 | vp_height = 1080 40 | dpg.create_viewport( 41 | title=midiexplorer.APPLICATION_NAME, 42 | # small_icon=small_icon, # Set later 43 | # large_icon=large_icon, # Set later 44 | width=vp_width, 45 | height=vp_height, 46 | x_pos=0, 47 | y_pos=0, 48 | vsync=True, 49 | always_on_top=DEBUG, 50 | decorated=not DEBUG, 51 | ) 52 | 53 | # ---------------- 54 | # Logging system 55 | # Initialized ASAP 56 | # ---------------- 57 | Logger.log("Initializing logging window") 58 | midiexplorer.gui.windows.log.create() 59 | logger: mvLogger = midiexplorer.gui.helpers.logger.Logger('log_win') 60 | if DEBUG: 61 | logger.log_level = midiexplorer.gui.helpers.logger.LoggingLevel.TRACE 62 | else: 63 | logger.log_level = midiexplorer.gui.helpers.logger.LoggingLevel.INFO 64 | logger.log_debug(f"Logger started") 65 | 66 | # ---------------- 67 | # MIDI I/O system 68 | # Initialized ASAP 69 | # ---------------- 70 | logger.log("Initializing MIDI I/O") 71 | try: 72 | midiexplorer.midi.init() 73 | except ValueError as e: 74 | logger.log_critical(f"Unable to initialize MIDI I/O: {e}") 75 | # TODO: error popup? 76 | # We need a window! 77 | # TODO: bail out? 78 | pass 79 | 80 | # ------- 81 | # Windows 82 | # ------- 83 | midiexplorer.gui.helpers.menu.create() 84 | midiexplorer.gui.windows.conn.create() 85 | midiexplorer.gui.windows.hist.create() 86 | midiexplorer.gui.windows.mon.create() 87 | midiexplorer.gui.windows.gen.create() 88 | midiexplorer.gui.windows.smf.create() 89 | 90 | # ------------------ 91 | # Keyboard shortcuts 92 | # 93 | # Don't forget to update menus! 94 | # ------------------ 95 | with dpg.handler_registry(): 96 | # F1: connections 97 | dpg.add_key_press_handler(key=dpg.mvKey_F1, callback=midiexplorer.gui.windows.conn.toggle) 98 | # F2: history 99 | dpg.add_key_press_handler(key=dpg.mvKey_F2, callback=midiexplorer.gui.windows.hist.toggle) 100 | # F3: monitor 101 | dpg.add_key_press_handler(key=dpg.mvKey_F3, callback=midiexplorer.gui.windows.mon.toggle) 102 | # F4: generator 103 | dpg.add_key_press_handler(key=dpg.mvKey_F4, callback=midiexplorer.gui.windows.gen.toggle) 104 | # F5: SMF 105 | dpg.add_key_press_handler(key=dpg.mvKey_F5, callback=midiexplorer.gui.windows.smf.toggle) 106 | # Fullscreen on F11 107 | dpg.add_key_press_handler(key=dpg.mvKey_F11, callback=midiexplorer.gui.toggle_fullscreen) 108 | # Log on F12 109 | dpg.add_key_press_handler(key=dpg.mvKey_F12, callback=midiexplorer.gui.windows.log.toggle) 110 | 111 | # ----- 112 | # Theme 113 | # ----- 114 | # https://dearpygui.readthedocs.io/en/latest/documentation/themes.html 115 | # TODO: Custom theme? 116 | 117 | # ----- 118 | # Icons 119 | # ----- 120 | # Icons must be set before showing viewport (Can also be set when instantiating the viewport) 121 | icons_root = importlib.resources.files(midiexplorer.icons) 122 | logger.log(f"Icons root: {icons_root}") 123 | small_icon = str(icons_root.joinpath('midiexplorer.ico')) 124 | large_icon = str(icons_root.joinpath('midiexplorer.ico')) 125 | dpg.set_viewport_small_icon(small_icon) 126 | dpg.set_viewport_large_icon(large_icon) 127 | 128 | # ----- 129 | # Fonts 130 | # ----- 131 | # https://dearpygui.readthedocs.io/en/latest/documentation/fonts.html 132 | 133 | # FIXME: Improve font rendering on Windows. 134 | # The following method doesn’t yield satisfying results on 1920x1080 monitors. 135 | # Needs more testing. 136 | # font_multiplier = 1 137 | # 138 | # class ProcessDpiAwareness(IntEnum): 139 | # # https://learn.microsoft.com/en-us/windows/win32/api/shellscalingapi/ne-shellscalingapi-process_dpi_awareness 140 | # PROCESS_DPI_UNAWARE = 0 141 | # PROCESS_SYSTEM_DPI_AWARE = 1 142 | # PROCESS_PER_MONITOR_DPI_AWARE = 2 143 | # 144 | # if sys.platform == 'win32': 145 | # font_multiplier = 2 146 | # dpg.set_global_font_scale(1 / font_multiplier) # TODO: HiDPI support? 147 | # import ctypes 148 | # ctypes.windll.shcore.SetProcessDpiAwareness(ProcessDpiAwareness.PROCESS_PER_MONITOR_DPI_AWARE) 149 | 150 | fonts_root = importlib.resources.files(midiexplorer.fonts) 151 | logger.log(f"Fonts root: {fonts_root}") 152 | with dpg.font_registry(): 153 | dpg.add_font(str(fonts_root.joinpath('Roboto-Regular.ttf')), 14, tag='default_font') 154 | dpg.add_font(str(fonts_root.joinpath('RobotoMono-Regular.ttf')), 14, tag='mono_font') 155 | 156 | dpg.bind_font('default_font') 157 | 158 | log_win_textbox = dpg.get_item_children('log_win', slot=midiexplorer.gui.helpers.constants.slots.Slots.MOST)[2] 159 | dpg.bind_item_font(log_win_textbox, 'mono_font') 160 | 161 | dpg.bind_item_font('hist_data_table', 'mono_font') 162 | 163 | if DEBUG: 164 | dpg.bind_item_font('mon_midi_mode', 'mono_font') 165 | dpg.bind_item_font('mon_status_container', 'mono_font') 166 | dpg.bind_item_font('mon_notes_container', 'mono_font') 167 | dpg.bind_item_font('mon_controllers_container', 'mono_font') 168 | dpg.bind_item_font('mon_program_container', 'mono_font') 169 | dpg.bind_item_font('mon_sysex_container', 'mono_font') 170 | 171 | dpg.bind_item_font('generator_container', 'mono_font') 172 | 173 | if DEBUG: 174 | dpg.bind_item_font('smf_container', 'mono_font') 175 | 176 | dpg.setup_dearpygui() 177 | dpg.show_viewport() 178 | 179 | 180 | def toggle_fullscreen(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 181 | """Callback to toggle the window visibility. 182 | 183 | :param sender: argument is used by DPG to inform the callback 184 | which item triggered the callback by sending the tag 185 | or 0 if trigger by the application. 186 | :param app_data: argument is used DPG to send information to the callback 187 | i.e. the current value of most basic widgets. 188 | :param user_data: argument is Optionally used to pass your own python data into the function. 189 | 190 | """ 191 | if DEBUG: 192 | enable_dpg_cb_debugging(sender, app_data, user_data) 193 | 194 | dpg.toggle_viewport_fullscreen() 195 | 196 | menu_item = 'menu_display_fullscreen' 197 | if sender != menu_item: # Update menu checkmark when coming from the shortcut handler 198 | dpg.set_value(menu_item, not dpg.get_value(menu_item)) 199 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | __all__ = ['callbacks', 'config', 'constants', 'convert', 'logger', 'menu', 'probe', 'smf'] 8 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/callbacks/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | DearPy GUI Callbacks helpers. 9 | """ 10 | 11 | __all__ = ['debugging'] 12 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/callbacks/_sample.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Sample DearPyGUI callback. 9 | 10 | For reference only. 11 | """ 12 | 13 | from typing import Any, Optional 14 | 15 | from midiexplorer.__config__ import DEBUG 16 | from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging 17 | 18 | 19 | def callback(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 20 | """Generic Dear PyGui callback for debug purposes. 21 | 22 | :param sender: argument is used by DPG to inform the callback 23 | which item triggered the callback by sending the tag 24 | or 0 if trigger by the application. 25 | :param app_data: argument is used by DPG to send information to the callback 26 | i.e. the current value of most basic widgets. 27 | :param user_data: argument is Optionally used to pass your own python data into the function. 28 | 29 | """ 30 | if DEBUG: 31 | enable_dpg_cb_debugging(sender, app_data, user_data) 32 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/callbacks/debugging.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Common callback debugging technique. 9 | """ 10 | 11 | import inspect 12 | from typing import Any, Optional 13 | 14 | from midiexplorer.gui.helpers.logger import Logger 15 | 16 | 17 | def enable(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 18 | """Enables callback debugging to the logger. 19 | 20 | :param sender: argument is used by DPG to inform the callback 21 | which item triggered the callback by sending the tag 22 | or 0 if trigger by the application. 23 | :param app_data: argument is used by DPG to send information to the callback 24 | i.e. the current value of most basic widgets. 25 | :param user_data: argument is Optionally used to pass your own python data into the function. 26 | 27 | """ 28 | logger = Logger() 29 | 30 | # Debug 31 | stack_frame = inspect.stack()[1] 32 | logger.log_debug(f"Callback {stack_frame.function} ({stack_frame.filename} line {stack_frame.lineno}):") 33 | logger.log_debug(f"\tSender: {sender!r}") 34 | logger.log_debug(f"\tApp data: {app_data!r}") 35 | logger.log_debug(f"\tUser data: {user_data!r}") 36 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/config.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Configuration file management. 9 | """ 10 | # FIXME: store preferences/settings 11 | 12 | import os.path 13 | from pathlib import Path 14 | 15 | from dearpygui import dearpygui as dpg 16 | 17 | from midiexplorer.__config__ import INIT_FILENAME 18 | 19 | 20 | def _do_load(_, app_data) -> None: 21 | """Loads a configuration from selected file. 22 | 23 | :param _: Sender is ignored 24 | :param app_data: Selected file metadata 25 | 26 | """ 27 | # FIXME: Does not work after creating the viewport! 28 | dpg.configure_app(load_init_file=app_data['file_path_name']) 29 | 30 | 31 | def _do_save_as(_, app_data) -> None: 32 | """Saves the current configuration in the selected file. 33 | 34 | :param _: Sender is ignored 35 | :param app_data: Selected file metadata 36 | 37 | """ 38 | dpg.save_init_file(app_data['file_path_name']) 39 | 40 | 41 | def clear() -> None: 42 | """Removes the default configuration. 43 | 44 | """ 45 | if os.path.exists(INIT_FILENAME): 46 | os.remove(INIT_FILENAME) 47 | 48 | 49 | def create_selectors() -> None: 50 | """Creates config file selector dialogs. 51 | 52 | """ 53 | with dpg.file_dialog( 54 | tag='conf_load', 55 | label="Load configuration", 56 | min_size=(640, 480), 57 | show=False, 58 | modal=True, 59 | directory_selector=False, 60 | default_filename=Path(INIT_FILENAME).stem, 61 | callback=_do_load, 62 | file_count=100, 63 | ): 64 | dpg.add_file_extension('.ini') 65 | 66 | with dpg.file_dialog( 67 | tag='conf_saveas', 68 | label="Save configuration as", 69 | min_size=(640, 480), 70 | show=False, 71 | modal=True, 72 | directory_selector=False, 73 | default_filename=Path(INIT_FILENAME).stem, 74 | callback=_do_save_as, 75 | ): 76 | dpg.add_file_extension('.ini') 77 | 78 | 79 | def load_file() -> None: 80 | """Shows the configuration file selector for loading. 81 | 82 | """ 83 | dpg.show_item('conf_load') 84 | 85 | 86 | def save_file() -> None: 87 | """Saves the current configuration to the default file. 88 | 89 | """ 90 | dpg.save_init_file(INIT_FILENAME) 91 | 92 | 93 | def save_file_as() -> None: 94 | """Shows the configuration file selector for saving as. 95 | 96 | """ 97 | dpg.show_item('conf_saveas') 98 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/constants/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Useful DearPy GUI global constants for readability. 9 | """ 10 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/constants/slots.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | DearPyGui Slot types. 9 | """ 10 | from enum import IntEnum 11 | 12 | 13 | class Slots(IntEnum): 14 | SPECIAL = 0 15 | MOST = 1 16 | DRAW = 2 17 | DRAG_PAYLOAD = 3 18 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/convert.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Data conversions. 9 | """ 10 | from dearpygui import dearpygui as dpg 11 | 12 | 13 | def add_string_value_preconv(tag: str) -> None: 14 | """Add string value with pre-converted values. 15 | 16 | :param tag: String value tag name 17 | """ 18 | dpg.add_string_value(tag=tag) 19 | if tag == 'syx_payload': 20 | dpg.add_string_value(tag=f'{tag}_char') 21 | dpg.add_string_value(tag=f'{tag}_hex') 22 | dpg.add_string_value(tag=f'{tag}_bin') 23 | dpg.add_string_value(tag=f'{tag}_dec') 24 | 25 | 26 | def convert_to(unit: chr, values: int | tuple[int] | list[int], length, padding) -> str: 27 | """Converts a single integer or a group to a text representation in the specified unit. 28 | 29 | :param unit: Unit to convert to (Format specification type) 30 | :param values: Value(s) to convert 31 | :param length: Conversion length 32 | :param padding: Prefixed padding length 33 | :return: Text representation of value(s) in unit format 34 | """ 35 | unit_name = "Unknown" 36 | if unit == 'X': 37 | unit_name = "Hexadecimal" 38 | if unit == 'd': 39 | unit_name = "Decimal" 40 | if unit == 'b': 41 | unit_name = "Binary" 42 | if unit == 'c': 43 | unit_name = "Character" 44 | unit_name_padding = 12 - len(unit_name) 45 | 46 | converted_values = "" 47 | if values is not None: 48 | if isinstance(values, int): 49 | converted_values += f"{' ':{padding}}{values:0{length}{unit}}" 50 | else: 51 | for value in values: 52 | converted_values += f"{' ':{padding}}{value:0{length}{unit}}" 53 | return f"{unit_name}:{' ':{unit_name_padding}}{converted_values.rstrip()}" 54 | 55 | 56 | def conv2hex(values: int | tuple[int] | list[int], length: int = 2, padding: int = 7) -> str: 57 | """Converts a group of integers or a single integer to its hexadecimal text representation. 58 | 59 | :param values: Value(s) to convert 60 | :param length: Conversion length 61 | :param padding: Prefixed padding length 62 | :return: Text representation of value(s) in hexadecimal format 63 | """ 64 | return convert_to('X', values, length, padding) 65 | 66 | 67 | def conv2dec(values: int | tuple[int] | list[int], length: int = 3, padding: int = 6) -> str: 68 | """Converts a group of integers or a single integer to its decimal text representation. 69 | 70 | :param values: Value(s) to convert 71 | :param length: Conversion length 72 | :param padding: Prefixed padding length 73 | :return: Text representation of value(s) in decimal format 74 | """ 75 | return convert_to('d', values, length, padding) 76 | 77 | 78 | def conv2bin(values: int | tuple[int] | list[int], length: int = 8, padding: int = 1) -> str: 79 | """Converts a group of integers or a single integer to its binary text representation. 80 | 81 | :param values: Value(s) to convert 82 | :param length: Conversion length 83 | :param padding: Prefixed padding length 84 | :return: Text representation of value(s) in binary format 85 | """ 86 | return convert_to('b', values, length, padding) 87 | 88 | 89 | def conv2char(values: int | tuple[int] | list[int]) -> str: 90 | """Converts a group of integers or a single integer to its ASCII text representation. 91 | 92 | :param values: Value(s) to convert 93 | :return: Text representation of value(s) in ASCII format 94 | """ 95 | return convert_to('c', values, 1, 8) 96 | 97 | 98 | def set_value_preconv(source: str, value: int | tuple[int] | list[int]) -> None: 99 | """Set value and pre-converted values. 100 | 101 | :param source: Value source tag name 102 | :param value: Value to set 103 | """ 104 | dpg.set_value(source, str(value)) 105 | if source == 'syx_payload': 106 | dpg.set_value(f'{source}_char', conv2char(value)) 107 | dpg.set_value(f'{source}_hex', conv2hex(value)) 108 | dpg.set_value(f'{source}_bin', conv2bin(value)) 109 | dpg.set_value(f'{source}_dec', conv2dec(value)) 110 | 111 | 112 | def tooltip_conv(title: str, values: int | tuple[int] | list[int] | None = None, 113 | hlen: int = 2, dlen: int = 3, blen: int = 8) -> None: 114 | """Adds a tooltip with data converted to hexadecimal, decimal and binary. 115 | 116 | :param title: Tooltip title. 117 | :param values: Tooltip value(s) 118 | :param hlen: Hexadecimal length 119 | :param dlen: Decimal length 120 | :param blen: Binary length 121 | 122 | """ 123 | with dpg.tooltip(dpg.last_item()): 124 | dpg.add_text(f"{title}") 125 | hconv = conv2hex(values, hlen, blen - hlen + 1) 126 | dconv = conv2dec(values, dlen, blen - dlen + 1) 127 | bconv = conv2bin(values, blen) 128 | if values is not None: 129 | dpg.add_text() 130 | dpg.add_text(f"{hconv}") 131 | dpg.add_text(f"{dconv}") 132 | dpg.add_text(f"{bconv}") 133 | 134 | 135 | def tooltip_preconv(static_title: str | None = None, title_value_source: str | None = None, 136 | values_source: str | None = None) -> None: 137 | """Adds a tooltip with pre-converted data. 138 | 139 | :param static_title: Tooltip static title. 140 | :param title_value_source: Tooltip title text value source. 141 | :param values_source: Tooltip value(s) source tag name 142 | """ 143 | with dpg.tooltip(dpg.last_item()): 144 | if static_title: 145 | dpg.add_text(static_title) 146 | if title_value_source: 147 | dpg.add_text(source=title_value_source) 148 | dpg.add_text() 149 | if values_source == 'syx_payload': 150 | dpg.add_text(source=f'{values_source}_char') 151 | dpg.add_text(source=f'{values_source}_hex') 152 | dpg.add_text(source=f'{values_source}_dec') 153 | dpg.add_text(source=f'{values_source}_bin') 154 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/logger.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | GUI logging system. 9 | """ 10 | from enum import IntEnum 11 | 12 | # from dearpygui_ext.logger import mvLogger 13 | import dearpygui.dearpygui as dpg 14 | 15 | from midiexplorer.__config__ import DEBUG 16 | 17 | TRANSPARENT = (0, 0, 0, 0) 18 | GREEN = (0, 255, 0, 255) 19 | BLUE = (64, 128, 255, 255) 20 | WHITE = (255, 255, 255, 255) 21 | YELLOW = (255, 255, 0, 255) 22 | RED = (255, 0, 0, 255) 23 | 24 | 25 | class LoggingLevel(IntEnum): 26 | """Logging levels enum. 27 | 28 | """ 29 | TRACE = 0 30 | DEBUG = 1 31 | INFO = 2 32 | WARNING = 3 33 | ERROR = 4 34 | CRITICAL = 5 35 | 36 | 37 | class mvLogger: 38 | """Logger. 39 | 40 | Borrowed and modified from DearPyGUI_Ext for text selection 41 | See: https://github.com/hoffstadt/DearPyGui_Ext/issues/1 42 | MIT License 43 | Copyright (c) 2021 Raylock, LLC 44 | 45 | FIXME: Multiline messages are not displayed correctly (Only first line) 46 | """ 47 | def __init__(self, parent=None): 48 | 49 | self.log_level = 0 50 | self._auto_scroll = True 51 | self.filter_id = None 52 | if parent: 53 | self.window_id = parent 54 | else: 55 | self.window_id = dpg.add_window(label="mvLogger", pos=(200, 200), width=500, height=500) 56 | self.count = 0 57 | self.flush_count = 1000 58 | 59 | with dpg.group(horizontal=True, parent=self.window_id): 60 | dpg.add_checkbox(label="Auto-scroll", default_value=True, 61 | callback=lambda sender: self.auto_scroll(dpg.get_value(sender))) 62 | dpg.add_button(label="Clear", callback=lambda: dpg.delete_item(self.filter_id, children_only=True)) 63 | 64 | dpg.add_input_text(label="Filter", callback=lambda sender: dpg.set_value(self.filter_id, dpg.get_value(sender)), 65 | parent=self.window_id) 66 | self.child_id = dpg.add_child_window(parent=self.window_id, autosize_x=True, autosize_y=True) 67 | self.filter_id = dpg.add_filter_set(parent=self.child_id) 68 | 69 | with dpg.theme() as bg_theme: 70 | with dpg.theme_component(0): 71 | dpg.add_theme_color(dpg.mvThemeCol_FrameBg, TRANSPARENT) 72 | dpg.add_theme_color(dpg.mvThemeCol_Button, TRANSPARENT) 73 | dpg.bind_item_theme(self.child_id, bg_theme) 74 | 75 | with dpg.theme() as self.trace_theme: 76 | with dpg.theme_component(0): 77 | dpg.add_theme_color(dpg.mvThemeCol_Text, GREEN) 78 | 79 | with dpg.theme() as self.debug_theme: 80 | with dpg.theme_component(0): 81 | dpg.add_theme_color(dpg.mvThemeCol_Text, BLUE) 82 | 83 | with dpg.theme() as self.info_theme: 84 | with dpg.theme_component(0): 85 | dpg.add_theme_color(dpg.mvThemeCol_Text, WHITE) 86 | 87 | with dpg.theme() as self.warning_theme: 88 | with dpg.theme_component(0): 89 | dpg.add_theme_color(dpg.mvThemeCol_Text, YELLOW) 90 | 91 | with dpg.theme() as self.error_theme: 92 | with dpg.theme_component(0): 93 | dpg.add_theme_color(dpg.mvThemeCol_Text, RED) 94 | 95 | with dpg.theme() as self.critical_theme: 96 | with dpg.theme_component(0): 97 | dpg.add_theme_color(dpg.mvThemeCol_Text, RED) 98 | 99 | def auto_scroll(self, value): 100 | self._auto_scroll = value 101 | 102 | def _log(self, message, level): 103 | 104 | if level < self.log_level: 105 | return 106 | 107 | self.count += 1 108 | 109 | if self.count > self.flush_count: 110 | self.clear_log() 111 | 112 | theme = self.info_theme 113 | 114 | if level == LoggingLevel.TRACE: 115 | message = "[TRACE] " + message 116 | theme = self.trace_theme 117 | elif level == LoggingLevel.DEBUG: 118 | message = "[DEBUG] " + message 119 | theme = self.debug_theme 120 | elif level == LoggingLevel.INFO: 121 | message = "[INFO] " + message 122 | elif level == LoggingLevel.WARNING: 123 | message = "[WARNING] " + message 124 | theme = self.warning_theme 125 | elif level == LoggingLevel.ERROR: 126 | message = "[ERROR] " + message 127 | theme = self.error_theme 128 | elif level == LoggingLevel.CRITICAL: 129 | message = "[CRITICAL] " + message 130 | theme = self.critical_theme 131 | 132 | if DEBUG: 133 | new_log = dpg.add_button( 134 | label=message, 135 | parent=self.filter_id, filter_key=message, 136 | user_data=message, callback=lambda s, a, u: dpg.set_clipboard_text(u)) 137 | with dpg.tooltip(dpg.last_item()): 138 | dpg.add_text("Press to copy to clipboard") 139 | else: 140 | new_log = dpg.add_input_text( 141 | width=-1, # Full 142 | parent=self.filter_id, filter_key=message, 143 | default_value=message, readonly=True, 144 | #multiline=True, 145 | ) 146 | dpg.bind_item_theme(new_log, theme) 147 | if self._auto_scroll: 148 | dpg.set_y_scroll(self.child_id, -1.0) 149 | 150 | def log(self, message): 151 | self._log(message, LoggingLevel.TRACE) 152 | 153 | def log_debug(self, message): 154 | self._log(message, LoggingLevel.DEBUG) 155 | 156 | def log_info(self, message): 157 | self._log(message, LoggingLevel.INFO) 158 | 159 | def log_warning(self, message): 160 | self._log(message, LoggingLevel.WARNING) 161 | 162 | def log_error(self, message): 163 | self._log(message, LoggingLevel.ERROR) 164 | 165 | def log_critical(self, message): 166 | self._log(message, LoggingLevel.CRITICAL) 167 | 168 | def clear_log(self): 169 | dpg.delete_item(self.filter_id, children_only=True) 170 | self.count = 0 171 | 172 | 173 | class Logger: 174 | """Logger singleton. 175 | 176 | Allows sharing it globally. 177 | 178 | """ 179 | __instance: mvLogger | None = None 180 | # Allows logging messages before mvLogger window creation 181 | _startup_cache: (str, LoggingLevel) = [] 182 | 183 | def __new__(cls, parent: None | int | str = None) -> mvLogger: 184 | """Instantiates a new logger or retrieves the existing one. 185 | 186 | :param parent: The window ID or tag to which the logger should be attached. 187 | :return: A logger instance. 188 | :raises: ValueError -- A parent is required to initialize the Logger. 189 | 190 | """ 191 | if parent is None and Logger.__instance is None: 192 | raise ValueError("Please provide a parent to initialize the Logger") 193 | if parent is not None: 194 | Logger.__instance = mvLogger(parent) 195 | # Flush startup cache into window 196 | if cls._startup_cache: 197 | for item in cls._startup_cache: 198 | Logger.__instance._log(*item) 199 | cls._startup_cache.clear() 200 | if Logger.__instance is None: 201 | Logger.__instance = super(Logger, cls).__new__(cls) 202 | return cls.__instance 203 | 204 | @staticmethod 205 | def log(message: str, level: LoggingLevel = LoggingLevel.TRACE): 206 | if isinstance(Logger.__instance, mvLogger): 207 | Logger.__instance._log(message, level) 208 | else: 209 | Logger._startup_cache.append((message, level)) 210 | 211 | @staticmethod 212 | def log_debug(message: str): 213 | Logger.log(message, LoggingLevel.DEBUG) 214 | 215 | @staticmethod 216 | def log_warning(message: str): 217 | Logger.log(message, LoggingLevel.WARNING) 218 | 219 | @staticmethod 220 | def log_error(message: str): 221 | Logger.log(message, LoggingLevel.ERROR) 222 | 223 | @staticmethod 224 | def log_critical(message: str): 225 | Logger.log(message, LoggingLevel.CRITICAL) 226 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/menu.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Main menu. 9 | """ 10 | 11 | from dearpygui import dearpygui as dpg 12 | from dearpygui.demo import show_demo 13 | 14 | import midiexplorer.gui.helpers.config 15 | import midiexplorer.gui.helpers.logger 16 | import midiexplorer.gui.windows.about 17 | import midiexplorer.gui.windows.log 18 | from midiexplorer.__config__ import DEBUG 19 | 20 | 21 | def create() -> None: 22 | """Creates the main application menu. 23 | 24 | Including the menu bar, associated items and file selector dialogs. 25 | 26 | """ 27 | midiexplorer.gui.helpers.config.create_selectors() 28 | midiexplorer.gui.windows.about.create() 29 | 30 | with dpg.viewport_menu_bar(): 31 | with dpg.menu(label="File"): 32 | dpg.add_menu_item(label="Exit", callback=dpg.stop_dearpygui) 33 | 34 | if DEBUG: # FIXME: Currently unstable 35 | with dpg.menu(label="Configuration"): 36 | dpg.add_menu_item(label="Load", callback=midiexplorer.gui.helpers.config.load_file) 37 | dpg.add_menu_item(label="Save", callback=midiexplorer.gui.helpers.config.save_file) 38 | dpg.add_menu_item(label="Save as", callback=midiexplorer.gui.helpers.config.save_file_as) 39 | dpg.add_menu_item(label="Reset", callback=midiexplorer.gui.helpers.config.clear) 40 | 41 | with dpg.menu(label="Tools"): 42 | # Don't forget to update keyboard shortcuts! 43 | dpg.add_menu_item(label="Connections", 44 | tag='menu_tools_connections', 45 | shortcut="F1", 46 | check=True, 47 | default_value=True, 48 | callback=midiexplorer.gui.windows.conn.toggle) 49 | dpg.add_menu_item(label="History", 50 | tag='menu_tools_history', 51 | shortcut="F2", 52 | check=True, 53 | default_value=True, 54 | callback=midiexplorer.gui.windows.hist.toggle) 55 | dpg.add_menu_item(label="Monitor", 56 | tag='menu_tools_monitor', 57 | shortcut="F3", 58 | check=True, 59 | default_value=True, 60 | callback=midiexplorer.gui.windows.mon.toggle) 61 | dpg.add_menu_item(label="Generator", 62 | tag='menu_tools_generator', 63 | shortcut="F4", 64 | check=True, 65 | default_value=True, 66 | callback=midiexplorer.gui.windows.gen.toggle) 67 | dpg.add_menu_item(label="Standard MIDI File", 68 | tag='menu_tools_smf', 69 | shortcut="F5", 70 | check=True, 71 | default_value=False, 72 | callback=midiexplorer.gui.windows.smf.toggle) 73 | dpg.add_menu_item(label="Log", 74 | tag='menu_tools_log', 75 | shortcut="F12", 76 | check=True, 77 | default_value=DEBUG, 78 | callback=midiexplorer.gui.windows.log.toggle) 79 | 80 | with dpg.menu(label="Display"): 81 | dpg.add_menu_item(label="Toggle Fullscreen", 82 | tag='menu_display_fullscreen', 83 | shortcut="F11", 84 | check=True, 85 | default_value=False, 86 | callback=dpg.toggle_viewport_fullscreen) 87 | 88 | with dpg.menu(label="Help"): 89 | if DEBUG: 90 | with dpg.menu(label="Debug"): 91 | dpg.add_menu_item(label="Show About", callback=lambda: dpg.show_tool(dpg.mvTool_About)) 92 | dpg.add_menu_item(label="Show Metrics", callback=lambda: dpg.show_tool(dpg.mvTool_Metrics)) 93 | dpg.add_menu_item(label="Show Documentation", callback=lambda: dpg.show_tool(dpg.mvTool_Doc)) 94 | dpg.add_menu_item(label="Show Debug", callback=lambda: dpg.show_tool(dpg.mvTool_Debug)) 95 | dpg.add_menu_item(label="Show Style Editor", callback=lambda: dpg.show_tool(dpg.mvTool_Style)) 96 | dpg.add_menu_item(label="Show Font Manager", callback=lambda: dpg.show_tool(dpg.mvTool_Font)) 97 | dpg.add_menu_item(label="Show Item Registry", 98 | callback=lambda: dpg.show_tool(dpg.mvTool_ItemRegistry)) 99 | dpg.add_menu_item(label="Show ImGui Demo", callback=dpg.show_imgui_demo) 100 | dpg.add_menu_item(label="Show ImPlot Demo", callback=dpg.show_implot_demo) 101 | dpg.add_menu_item(label="Show Dear PyGui Demo", callback=show_demo) 102 | dpg.add_menu_item(label="About", callback=midiexplorer.gui.windows.about.toggle) 103 | # TODO: Add documentation 104 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/probe.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | import mido 8 | 9 | import midiexplorer.gui.windows.hist.data 10 | import midiexplorer.gui.windows.mon.data 11 | from midiexplorer.gui.helpers.logger import Logger 12 | from midiexplorer.midi.timestamp import Timestamp 13 | 14 | 15 | def add(timestamp: Timestamp, source: str, data: mido.Message) -> None: 16 | """Decodes and presents data received from the probe. 17 | 18 | :param timestamp: System timestamp 19 | :param source: Input name 20 | :param data: MIDI data 21 | 22 | """ 23 | logger = Logger() 24 | 25 | logger.log_debug(f"Adding data from {source} to probe at {timestamp}: {data!r}") 26 | 27 | midiexplorer.gui.windows.mon.data.update_gui_monitor(data) 28 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/helpers/smf.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Standard MIDI File (SMF) menus callbacks. 9 | """ 10 | 11 | from dearpygui import dearpygui as dpg 12 | from mido import MidiFile 13 | 14 | import midiexplorer.gui.windows.smf 15 | from midiexplorer.__config__ import DEBUG 16 | 17 | 18 | def _do_load(_, app_data) -> None: 19 | """Loads an SMF from selected file. 20 | 21 | :param _: Sender is ignored 22 | :param app_data: Selected file metadata 23 | 24 | """ 25 | # TODO: sanity checks? 26 | 27 | # Logger().log_debug(f"{app_data!r}") 28 | filename = app_data['file_path_name'] 29 | 30 | # Raw file 31 | with open(filename, 'rb') as file: 32 | contents = file.read() 33 | # Logger().log_debug(f"{contents!r}") 34 | 35 | # Decoded file 36 | mid = MidiFile(filename, clip=True, debug=DEBUG, 37 | # charset='ascii', 38 | ) 39 | # Logger().log_debug(f"{mid!r}") 40 | 41 | midiexplorer.gui.windows.smf.populate(contents, mid) 42 | 43 | 44 | def _do_save_as(_, app_data) -> None: 45 | """Saves the current SMF to the selected file. 46 | 47 | :param _: Sender is ignored 48 | :param app_data: Selected file metadata 49 | 50 | """ 51 | raise NotImplementedError 52 | 53 | 54 | def _set_supported_extensions() -> None: 55 | """Sets the supported extensions to the file dialog. 56 | """ 57 | dpg.add_file_extension('.mid') 58 | dpg.add_file_extension('.midi') 59 | dpg.add_file_extension('.smf') 60 | if DEBUG: # TODO: Implement these formats! 61 | dpg.add_file_extension('.rmid') 62 | dpg.add_file_extension('.xmf') 63 | dpg.add_file_extension('.syx') 64 | dpg.add_file_extension('.kar') 65 | 66 | 67 | def create_selectors() -> None: 68 | """Creates SMF selector dialogs. 69 | 70 | """ 71 | with dpg.file_dialog( 72 | tag='smf_open', 73 | label="Open SMF", 74 | min_size=(640, 480), 75 | show=False, 76 | modal=True, 77 | directory_selector=False, 78 | callback=_do_load, 79 | file_count=100, 80 | ): 81 | _set_supported_extensions() 82 | 83 | with dpg.file_dialog( 84 | tag='smf_saveas', 85 | label="Save SMF as", 86 | min_size=(640, 480), 87 | show=False, 88 | modal=True, 89 | directory_selector=False, 90 | callback=_do_save_as, 91 | ): 92 | _set_supported_extensions() 93 | 94 | 95 | def open_file() -> None: 96 | """Shows the SMF selector for loading. 97 | 98 | """ 99 | dpg.show_item('smf_open') 100 | 101 | 102 | def save_file() -> None: 103 | """Saves to the currently open SMF. 104 | 105 | """ 106 | raise NotImplementedError 107 | 108 | 109 | def save_file_as() -> None: 110 | """Shows the SMF selector for saving as. 111 | 112 | """ 113 | dpg.show_item('smf_saveas') 114 | 115 | 116 | def close_file() -> None: 117 | """Closes the current SMF. 118 | 119 | """ 120 | midiexplorer.gui.windows.smf.init() 121 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | DearPy GUI Windows. 9 | """ 10 | 11 | __all__ = ['about', 'conn', 'gen', 'hist', 'log', 'mon', 'smf'] 12 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/about.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | About window layout and content. 9 | """ 10 | import array 11 | import importlib.metadata 12 | import importlib.resources 13 | import platform 14 | from typing import Any, Optional 15 | 16 | import mido 17 | from PIL import Image 18 | from dearpygui import dearpygui as dpg 19 | 20 | import midiexplorer.__about__ 21 | from midiexplorer import icons 22 | from midiexplorer.__config__ import DEBUG 23 | from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging 24 | 25 | 26 | def load_icon(img_file: str) -> (int, int, [float]): 27 | """Alternative to dpg.load_image that works properly with pathlib.Path! 28 | 29 | Borrowed from https://github.com/hoffstadt/DearPyGui/issues/1796#issuecomment-1189513319 30 | and modified to remove numpy dependency. 31 | """ 32 | with importlib.resources.open_binary(midiexplorer.icons, img_file) as fh: 33 | image = Image.open(fh) 34 | width, height = image.size 35 | data = array.array('B', image.tobytes()) 36 | data = [(a / 255.0) for a in data] 37 | return width, height, data 38 | 39 | 40 | def create() -> None: 41 | """Creates the about window. 42 | 43 | """ 44 | with dpg.window( 45 | tag='about_win', 46 | label="About", 47 | use_internal_label=False, 48 | modal=True, 49 | no_collapse=True, 50 | no_background=False, 51 | no_move=True, 52 | pos=(dpg.get_viewport_width()/3, 0), 53 | autosize=True, 54 | show=False, 55 | ): 56 | logo_size = 128 57 | title_color = (0, 255, 255) # Cyan 58 | text_indent = 50 59 | 60 | # ---- 61 | # Logo 62 | # ---- 63 | width, height, data = load_icon(f'midiexplorer_{logo_size}.png') 64 | with dpg.texture_registry(): 65 | dpg.add_static_texture(width, height, data, tag='logo') 66 | with dpg.drawlist(width=width, height=height): 67 | dpg.draw_image('logo', pmin=(0, 0), pmax=(width, height)) 68 | 69 | # ----- 70 | # Title 71 | # ----- 72 | dpg.add_text(midiexplorer.APPLICATION_NAME, color=title_color) 73 | dpg.add_text(f"Version {midiexplorer.__about__.__version__}.") 74 | dpg.add_text("Yet another MIDI monitor, analyzer, debugger and manipulation tool.") 75 | 76 | # ------ 77 | # Author 78 | # ------ 79 | dpg.add_text("Author", color=title_color) 80 | dpg.add_text("Raphaël Doursenaud") 81 | 82 | # ------- 83 | # License 84 | # ------- 85 | dpg.add_text("License", color=title_color) 86 | width, height, data = load_icon('gplv3-or-later-sm.png') 87 | with dpg.texture_registry(): 88 | dpg.add_static_texture(width, height, data, tag='gpl_logo') 89 | with dpg.drawlist(width=width, height=height): 90 | dpg.draw_image('gpl_logo', pmin=(0, 0), pmax=(width, height)) 91 | dpg.add_text("Copyright ©2021-2022 Raphaël Doursenaud") 92 | dpg.add_text("""This program is free software: you can redistribute it and/or modify 93 | it under the terms of the GNU General Public License as published by 94 | the Free Software Foundation, either version 3 of the License, or 95 | (at your option) any later version. 96 | 97 | This program is distributed in the hope that it will be useful, 98 | but WITHOUT ANY WARRANTY; without even the implied warranty of 99 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 100 | GNU General Public License for more details. 101 | 102 | You should have received a copy of the GNU General Public License 103 | along with this program. If not, see .""") 104 | dpg.add_separator() 105 | 106 | # ------- 107 | # Credits 108 | # ------- 109 | dpg.add_text("Credits", color=title_color) 110 | 111 | dpg.add_text(f"""Python {platform.python_version()} 112 | Used under the terms of the PSF License Agreement.""", 113 | bullet=True) 114 | 115 | dpg.add_text(f"""mido {mido.version_info} 116 | Copyright (c) 2013-infinity Ole Martin Bjørndalen 117 | Used under the terms of the MIT License.""", 118 | bullet=True) 119 | dpg.add_text("""python-rtmidi 120 | Copyright (c) 2012 - 2021 Christopher Arndt 121 | Used under the terms of the MIT License.""", 122 | indent=text_indent) 123 | dpg.add_text("""RtMidi 124 | Copyright (c) 2003-2021 Gary P. Scavone 125 | Used under the terms of the MIT License.""", 126 | indent=text_indent) 127 | 128 | dpg.add_text(f"""Dear PyGui {dpg.get_app_configuration()['version']}. 129 | Copyright (c) 2021 Dear PyGui, LLC 130 | Used under the terms of the MIT License.""", 131 | bullet=True) 132 | dpg.add_text("""Dear ImGui 133 | Copyright (c) 2014-2022 Omar Cornut 134 | Used under the terms of the MIT License.""", 135 | indent=text_indent) 136 | 137 | # ----- 138 | # Fonts 139 | # ----- 140 | dpg.add_text("Fonts", color=title_color) 141 | dpg.add_text("""Roboto and Roboto Mono 142 | Copyright (c) 2015 The Roboto Project Authors 143 | Used under the terms of the Apache License, Version 2.0.""", 144 | bullet=True) 145 | 146 | # ------------ 147 | # Logo & icons 148 | # ------------ 149 | dpg.add_text("Logo and icons", color=title_color) 150 | dpg.add_text("Composite work based upon:") 151 | dpg.add_text("""MIDI Connector 152 | Copyright Fred the Oyster 153 | Used under the terms of the Creative Commons Attribution-Share Alike 4.0 International license.""", 154 | bullet=True) 155 | dpg.add_text("""Steering wheel 156 | Copyright Spider 157 | Used under the terms of the Creative Commons Attribution 4.0 International license.""", 158 | bullet=True) 159 | 160 | # ---------- 161 | # Trademarks 162 | # ---------- 163 | dpg.add_text("Trademarks", color=title_color) 164 | dpg.add_text( 165 | """MIDI is a trademark of the MIDI Manufacturers Association (MMA) in the United States of America. 166 | This is not a registered trademark in the European Union and France where I reside. 167 | Other trademarks are property of their respective owners and used fairly for descriptive and nominative purposes only.""" 168 | ) 169 | 170 | 171 | def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 172 | """Callback to toggle the about window visibility. 173 | 174 | :param sender: argument is used by DPG to inform the callback 175 | which item triggered the callback by sending the tag 176 | or 0 if trigger by the application. 177 | :param app_data: argument is used DPG to send information to the callback 178 | i.e. the current value of most basic widgets. 179 | :param user_data: argument is Optionally used to pass your own python data into the function. 180 | 181 | """ 182 | if DEBUG: 183 | enable_dpg_cb_debugging(sender, app_data, user_data) 184 | 185 | dpg.configure_item('about_win', show=not dpg.is_item_visible('about_win')) 186 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/gen.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Generator window and management. 9 | """ 10 | import time 11 | from typing import Any, Optional 12 | 13 | import mido 14 | from dearpygui import dearpygui as dpg 15 | 16 | import midiexplorer.gui.windows.hist.data 17 | from midiexplorer.__config__ import DEBUG 18 | from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging 19 | from midiexplorer.gui.helpers.logger import Logger 20 | from midiexplorer.midi.timestamp import Timestamp 21 | 22 | 23 | def create() -> None: 24 | """Creates the generator window. 25 | 26 | """ 27 | with dpg.window( 28 | tag='gen_win', 29 | label="Generator", 30 | use_internal_label=False, 31 | width=dpg.get_viewport_width()/2, 32 | height=110, 33 | no_close=True, 34 | collapsed=False, 35 | pos=[dpg.get_viewport_width()/2, (dpg.get_viewport_height()/3*2)+20], 36 | ): 37 | with dpg.group(tag='generator_container'): 38 | dpg.add_input_text( 39 | tag='generator_raw_message', 40 | label="Raw Message", 41 | hint="XXYYZZ (HEX)", 42 | hexadecimal=True, 43 | callback=decode, 44 | ) 45 | dpg.add_input_text( 46 | label="Decoded", 47 | readonly=True, 48 | hint="Automatically decoded raw message", 49 | tag='generator_decoded_message', 50 | ) 51 | dpg.add_button( 52 | tag="generator_send_button", 53 | label="Send", 54 | enabled=False, 55 | callback=send, 56 | ) 57 | 58 | 59 | def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 60 | """Callback to toggle the window visibility. 61 | 62 | :param sender: argument is used by DPG to inform the callback 63 | which item triggered the callback by sending the tag 64 | or 0 if trigger by the application. 65 | :param app_data: argument is used DPG to send information to the callback 66 | i.e. the current value of most basic widgets. 67 | :param user_data: argument is Optionally used to pass your own python data into the function. 68 | 69 | """ 70 | if DEBUG: 71 | enable_dpg_cb_debugging(sender, app_data, user_data) 72 | 73 | dpg.configure_item('gen_win', show=not dpg.is_item_visible('gen_win')) 74 | 75 | menu_item = 'menu_tools_generator' 76 | if sender != menu_item: # Update menu checkmark when coming from the shortcut handler 77 | dpg.set_value(menu_item, not dpg.get_value(menu_item)) 78 | 79 | 80 | def decode(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 81 | """Callback to decode raw MIDI message input. 82 | 83 | :param sender: argument is used by DPG to inform the callback 84 | which item triggered the callback by sending the tag 85 | or 0 if trigger by the application. 86 | :param app_data: argument is used DPG to send information to the callback 87 | i.e. the current value of most basic widgets. 88 | :param user_data: argument is Optionally used to pass your own python data into the function. 89 | 90 | """ 91 | logger = Logger() 92 | 93 | if DEBUG: 94 | enable_dpg_cb_debugging(sender, app_data, user_data) 95 | 96 | warning = None 97 | try: 98 | decoded: mido.Message = mido.Message.from_hex(app_data) 99 | except (TypeError, ValueError, IndexError) as error: 100 | warning = f"Warning: {error!s}" 101 | pass 102 | 103 | if warning is None: 104 | logger.log_debug(f"Raw message {app_data} decoded to: {decoded!r}.") 105 | dpg.set_value('generator_decoded_message', decoded) 106 | dpg.enable_item('generator_send_button') 107 | dpg.set_item_user_data('generator_send_button', decoded) 108 | else: 109 | logger.log_warning(f"Error decoding raw message {app_data}: {warning}") 110 | dpg.set_value('generator_decoded_message', warning) 111 | dpg.disable_item('generator_send_button') 112 | dpg.set_item_user_data('generator_send_button', None) 113 | 114 | 115 | def send(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 116 | """Callback to send raw MIDI message from input. 117 | 118 | :param sender: argument is used by DPG to inform the callback 119 | which item triggered the callback by sending the tag 120 | or 0 if trigger by the application. 121 | :param app_data: argument is used DPG to send information to the callback 122 | i.e. the current value of most basic widgets. 123 | :param user_data: argument is Optionally used to pass your own python data into the function. 124 | 125 | """ 126 | 127 | # Compute timestamp and delta ASAP 128 | timestamp = Timestamp() 129 | 130 | logger = Logger() 131 | 132 | if DEBUG: 133 | enable_dpg_cb_debugging(sender, app_data, user_data) 134 | 135 | port = dpg.get_item_user_data('gen_out') 136 | if port: 137 | port.port.send(user_data) 138 | midiexplorer.gui.windows.hist.data.add(data=user_data, source='Generator', destination=port.label, 139 | timestamp=timestamp) 140 | else: 141 | logger.log_warning("Generator output is not connected to anything.") 142 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/hist/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | History window and management. 9 | """ 10 | from typing import Any, Optional 11 | 12 | from dearpygui import dearpygui as dpg 13 | 14 | from midiexplorer.__config__ import DEBUG 15 | from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging 16 | from midiexplorer.gui.windows.hist.data import clear_hist_data_table 17 | 18 | 19 | def _add_table_columns(): 20 | dpg.add_table_column(label="Timestamp (s)") 21 | dpg.add_table_column(label="Delta (ms)") 22 | dpg.add_table_column(label="Source") 23 | dpg.add_table_column(label="Destination") 24 | dpg.add_table_column(label="Raw Message (HEX)") 25 | if DEBUG: 26 | dpg.add_table_column(label="Decoded\nMessage") 27 | dpg.add_table_column(label="Status") 28 | dpg.add_table_column(label="Channel") 29 | dpg.add_table_column(label="Data 1") 30 | dpg.add_table_column(label="Data 2") 31 | dpg.add_table_column(label="Select", width_fixed=True, width=0, no_header_width=True, no_header_label=True) 32 | 33 | 34 | def create() -> None: 35 | """Creates the history window. 36 | 37 | """ 38 | 39 | # -------------------- 40 | # History window 41 | # -------------------- 42 | with dpg.window( 43 | tag='hist_win', 44 | label="History", 45 | use_internal_label=False, 46 | width=dpg.get_viewport_width()/2, 47 | height=(dpg.get_viewport_height()/3*2)-20, 48 | no_close=True, 49 | collapsed=False, 50 | pos=[0, (dpg.get_viewport_height()/3)+20], 51 | ): 52 | # ------------------- 53 | # History data table 54 | # ------------------- 55 | 56 | # Buttons 57 | with dpg.group(horizontal=True): 58 | dpg.add_text("Order:") 59 | dpg.add_radio_button(items=("Reversed", "Auto-Scroll"), label="Mode", tag='hist_data_table_mode', 60 | default_value="Reversed", horizontal=True) 61 | dpg.add_checkbox(label="Selection to Generator", tag='hist_data_to_gen', default_value=True) 62 | dpg.add_button(label="Clear", callback=clear_hist_data_table) 63 | 64 | # TODO: Allow sorting 65 | # TODO: timegraph? 66 | 67 | # Content details 68 | with dpg.table( 69 | tag='hist_data_table', 70 | header_row=True, 71 | #clipper= True, 72 | policy=dpg.mvTable_SizingStretchProp, 73 | freeze_rows=1, 74 | # sort_multi=True, 75 | # sort_tristate=True, # TODO: implement 76 | resizable=True, 77 | reorderable=True, # TODO: TableSetupColumn()? 78 | hideable=True, 79 | # sortable=True, # TODO: TableGetSortSpecs()? 80 | context_menu_in_body=True, 81 | row_background=True, 82 | borders_innerV=True, 83 | scrollY=True, 84 | ): 85 | _add_table_columns() 86 | 87 | 88 | def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 89 | """Callback to toggle the window visibility. 90 | 91 | :param sender: argument is used by DPG to inform the callback 92 | which item triggered the callback by sending the tag 93 | or 0 if trigger by the application. 94 | :param app_data: argument is used DPG to send information to the callback 95 | i.e. the current value of most basic widgets. 96 | :param user_data: argument is Optionally used to pass your own python data into the function. 97 | 98 | """ 99 | if DEBUG: 100 | enable_dpg_cb_debugging(sender, app_data, user_data) 101 | 102 | dpg.configure_item('hist_win', show=not dpg.is_item_visible('hist_win')) 103 | 104 | menu_item = 'menu_tools_history' 105 | if sender != menu_item: # Update menu checkmark when coming from the shortcut handler 106 | dpg.set_value(menu_item, not dpg.get_value(menu_item)) 107 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/hist/data.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | History data management. 9 | """ 10 | 11 | from typing import Any, Callable, Optional 12 | 13 | import midi_const 14 | import mido 15 | from dearpygui import dearpygui as dpg 16 | 17 | import midiexplorer.midi.mido2standard 18 | from midiexplorer.__config__ import DEBUG 19 | from midiexplorer.gui.helpers.callbacks.debugging import \ 20 | enable as enable_dpg_cb_debugging 21 | from midiexplorer.gui.helpers.constants.slots import Slots 22 | from midiexplorer.gui.helpers.convert import tooltip_conv 23 | from midiexplorer.gui.helpers.logger import Logger 24 | from midiexplorer.gui.windows.mon import notation_modes 25 | from midiexplorer.midi.timestamp import Timestamp 26 | 27 | S2MS = 1000 # Seconds to milliseconds ratio 28 | MAX_SIZE = 250 # Data table struggles with too many elements. 29 | 30 | ### 31 | # GLOBAL VARIABLES 32 | # 33 | # FIXME: global variables should ideally be eliminated as they are a poor programming style 34 | ### 35 | hist_data_counter = 0 36 | selected = None 37 | 38 | 39 | def clear_hist_data_table( 40 | sender: None | int | str = None, app_data: Any = None, user_data: Optional[Any] = None) -> None: 41 | """Clears the history data table. 42 | 43 | :param sender: argument is used by DPG to inform the callback 44 | which item triggered the callback by sending the tag 45 | or 0 if trigger by the application. 46 | :param app_data: argument is used DPG to send information to the callback 47 | i.e. the current value of most basic widgets. 48 | :param user_data: argument is Optionally used to pass your own python data into the function. 49 | 50 | """ 51 | global hist_data_counter, selected 52 | 53 | if DEBUG: 54 | enable_dpg_cb_debugging(sender, app_data, user_data) 55 | 56 | hist_data_counter = 0 57 | selected = None 58 | 59 | dpg.delete_item('hist_data_table', children_only=True, slot=Slots.MOST) 60 | 61 | 62 | def add(data: mido.Message, source: str, destination: str, timestamp: Timestamp) -> None: 63 | """Adds data to the history table. 64 | 65 | :param data: Midi message 66 | :param source: Source name 67 | :param destination: Destination name 68 | :param timestamp: Message data timestamp 69 | 70 | """ 71 | global hist_data_counter, selected 72 | 73 | logger = Logger() 74 | 75 | # Unselect 76 | if selected is not None: 77 | dpg.set_value(selected, False) # Deselect all items upon receiving new data 78 | 79 | # Flush data after a certain amount to avoid memory leak issues 80 | # TODO: add setting 81 | if hist_data_counter >= MAX_SIZE: 82 | # TODO: serialize chunk somewhere to allow unlimited scrolling when implemented 83 | clear_hist_data_table() 84 | 85 | chan_val, data0_name, data0_val, data0_dec, data1_name, data1_val, data1_dec = decode(data) 86 | 87 | # FIXME: data.time can also be 0 when using rtmidi time delta. How do we discriminate? Use another property in mido? 88 | if data.time and DEBUG: 89 | logger.log_debug("Timing: Using rtmidi time delta") 90 | delta = data.time 91 | else: 92 | logger.log_debug("Timing: Rtmidi time delta not available. Computing timestamp locally.") 93 | # FIXME: this delta is not relative to the same message train but to every handled messages! 94 | delta = timestamp.delta 95 | 96 | # Reversed order 97 | before = 0 98 | if dpg.get_value('hist_data_table_mode') == "Reversed" and hist_data_counter != 0: 99 | before = f'hist_data_{hist_data_counter - 1}' 100 | 101 | with dpg.table_row( 102 | tag=f'hist_data_{hist_data_counter}', 103 | parent='hist_data_table', 104 | before=before, 105 | ): 106 | 107 | # Timestamp (s) 108 | dpg.add_text(f"{timestamp.value:12.4f}") 109 | with dpg.tooltip(dpg.last_item()): 110 | dpg.add_text(f"{timestamp.value}") 111 | 112 | # Delta (ms) 113 | dpg.add_text(f"{delta * S2MS:12.4f}") 114 | with dpg.tooltip(dpg.last_item()): 115 | dpg.add_text(f"{delta * S2MS}") 116 | 117 | # Source 118 | dpg.add_text(source) 119 | with dpg.tooltip(dpg.last_item()): 120 | dpg.add_text(source) 121 | 122 | # Destination 123 | dpg.add_text(destination) 124 | with dpg.tooltip(dpg.last_item()): 125 | dpg.add_text(destination) 126 | 127 | # Raw message 128 | raw_label = data.hex() 129 | dpg.add_text(raw_label) 130 | tooltip_conv(raw_label, data.bin()) 131 | 132 | # Decoded message 133 | if DEBUG: 134 | dec_label = str(data) 135 | dpg.add_text(dec_label) 136 | with dpg.tooltip(dpg.last_item()): 137 | dpg.add_text(dec_label) 138 | 139 | # Status 140 | status_byte = midiexplorer.midi.mido2standard.get_status_by_type( 141 | data.type 142 | ) 143 | stat_label = midi_const.STATUS_BYTES[status_byte] 144 | dpg.add_text(stat_label) 145 | if hasattr(data, 'channel'): 146 | status_nibble = int((status_byte - data.channel) / 16) 147 | tooltip_conv(stat_label, status_nibble, hlen=1, dlen=2, blen=4) 148 | else: 149 | tooltip_conv(stat_label, status_byte) 150 | 151 | # Channel 152 | chan_label = "Global" 153 | if chan_val is not None: 154 | chan_label = chan_val + 1 # Human-readable format 155 | dpg.add_text(f'{chan_label: >2}') 156 | tooltip_conv(chan_label, chan_val, hlen=1, dlen=2, blen=4) 157 | 158 | # Helper function equivalent to str() but avoids displaying 'None'. 159 | xstr: Callable[[Any], str] = lambda s: '' if s is None else str(s) 160 | 161 | # Data 1 162 | if data0_dec: 163 | dpg.add_text(str(data0_dec)) 164 | else: 165 | dpg.add_text(f'{xstr(data1_val): >3}') 166 | prefix0 = "" 167 | if data0_name: 168 | prefix0 = data0_name + ": " 169 | tooltip_conv(prefix0 + xstr(data0_dec if data0_dec else data0_val), data0_val, blen=7) 170 | 171 | # Data 2 172 | dpg.add_text(f'{xstr(data1_val): >3}') 173 | prefix1 = "" 174 | if data1_name: 175 | prefix1 = data1_name + ": " 176 | tooltip_conv(prefix1 + xstr(data1_dec if data1_dec else data1_val), data1_val, blen=7) 177 | 178 | # Selectable 179 | target = f'selectable_{hist_data_counter}' 180 | dpg.add_selectable(span_columns=True, tag=target, callback=_selection, user_data=data) 181 | 182 | hist_data_counter += 1 183 | 184 | # TODO: per message type color coding 185 | # dpg.highlight_table_row(table_id, i, [255, 0, 0, 100]) 186 | 187 | # Autoscroll 188 | if dpg.get_value('hist_data_table_mode') == "Auto-Scroll": 189 | dpg.set_y_scroll('hist_data_table', -1.0) 190 | 191 | 192 | def _selection(sender, app_data, user_data): 193 | """History row selection management. 194 | 195 | :param sender: argument is used by DPG to inform the callback 196 | which item triggered the callback by sending the tag 197 | or 0 if trigger by the application. 198 | :param app_data: argument is used DPG to send information to the callback 199 | i.e. the current value of most basic widgets. 200 | :param user_data: argument is Optionally used to pass your own python data into the function. 201 | 202 | """ 203 | global selected 204 | 205 | if DEBUG: 206 | enable_dpg_cb_debugging(sender, app_data, user_data) 207 | 208 | # Single selection 209 | if selected is not None: 210 | dpg.set_value(selected, False) 211 | selected = sender 212 | 213 | message = user_data 214 | midiexplorer.gui.windows.mon.data.update_gui_monitor(message, static=True) 215 | 216 | # TODO: prevent overwriting user input 217 | if dpg.get_value('hist_data_to_gen'): 218 | dpg.set_value('generator_raw_message', message.hex()) 219 | dpg.set_value('generator_decoded_message', message) 220 | dpg.set_item_user_data('generator_send_button', message) 221 | dpg.enable_item('generator_send_button') 222 | 223 | 224 | def decode(data: mido.Message) -> tuple[int, int, int, int, int, int, int]: 225 | """Decodes the data. 226 | 227 | :param data: MIDI data. 228 | :return: Channel value, data 1 & 2 names, values and decoded. 229 | 230 | """ 231 | # Channel 232 | chan_val = None 233 | if hasattr(data, 'channel'): 234 | chan_val = data.channel 235 | 236 | # Data 1 & 2 237 | data0_name: str | False = False 238 | data0_val: int | tuple | None = None 239 | data0_dec: str | False = False 240 | data1_name: str | False = False 241 | data1_val: int | None = None 242 | data1_dec: str | False = False 243 | if 'note' in data.type: 244 | data0_name = "Note" 245 | data0_val: int = data.note 246 | data0_dec = notation_modes.get(dpg.get_value('notation_mode')).get(data.note) 247 | data1_name = "Velocity" 248 | data1_val: int = data.velocity 249 | elif 'polytouch' == data.type: 250 | data0_name = "Note" 251 | data0_val: int = data.note 252 | data0_dec = notation_modes.get(dpg.get_value('notation_mode')).get(data.note) 253 | data1_val: int = data.value 254 | elif 'control_change' == data.type: 255 | data0_name = "Controller" 256 | data0_val: int = data.control 257 | data0_dec = midi_const.CONTROLLER_NUMBERS.get(data.control) 258 | data1_name = "Value" 259 | data1_val: int = data.value 260 | elif 'program_change' == data.type: 261 | data0_name = "Program" 262 | data0_val: int = data.program 263 | # TODO: Optionally decode General MIDI names. 264 | elif 'aftertouch' == data.type: 265 | data0_name = "Value" 266 | data0_val: int = data.value 267 | elif 'pitchwheel' == data.type: 268 | data0_name = "Pitch" 269 | data0_val: int = data.pitch 270 | elif 'sysex' == data.type: 271 | data0_name = "Data" 272 | data0_val: tuple = data.data 273 | elif 'quarter_frame' == data.type: 274 | data0_name = "Frame type" 275 | data0_val = data.frame_type # TODO: decode 276 | data1_name = "Frame value" 277 | data1_val = data.frame_value # TODO: decode 278 | elif 'songpos' == data.type: 279 | data0_name = "Position Pointer" 280 | data0_val = data.pos 281 | elif 'song_select' == data.type: 282 | data0_name = "Song #" 283 | data0_val = data.song 284 | 285 | return chan_val, data0_name, data0_val, data0_dec, data1_name, data1_val, data1_dec 286 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/log.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Logging window. 9 | """ 10 | 11 | from typing import Any, Optional 12 | 13 | from dearpygui import dearpygui as dpg 14 | 15 | from midiexplorer.__config__ import DEBUG 16 | from midiexplorer.gui.helpers.callbacks.debugging import enable as enable_dpg_cb_debugging 17 | 18 | 19 | def create() -> None: 20 | """Creates the logging window. 21 | 22 | """ 23 | # TODO: allow logging to file 24 | # TODO: append/overwrite modes 25 | 26 | with dpg.window( 27 | tag='log_win', 28 | label="Log", 29 | use_internal_label=False, 30 | width=dpg.get_viewport_width(), 31 | height=dpg.get_viewport_height()/4, 32 | pos=[0, dpg.get_viewport_height()/4*3], 33 | show=DEBUG, 34 | ): 35 | pass 36 | 37 | 38 | def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 39 | """Callback to toggle the logging window visibility. 40 | 41 | :param sender: argument is used by DPG to inform the callback 42 | which item triggered the callback by sending the tag 43 | or 0 if trigger by the application. 44 | :param app_data: argument is used DPG to send information to the callback 45 | i.e. the current value of most basic widgets. 46 | :param user_data: argument is Optionally used to pass your own python data into the function. 47 | 48 | """ 49 | if DEBUG: 50 | enable_dpg_cb_debugging(sender, app_data, user_data) 51 | 52 | dpg.configure_item('log_win', show=not dpg.is_item_visible('log_win')) 53 | 54 | menu_item = 'menu_tools_log' 55 | if sender != menu_item: # Update menu checkmark when coming from the shortcut handler 56 | dpg.set_value(menu_item, not dpg.get_value(menu_item)) 57 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/mon/blink.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Monitoring blinking buttons. 9 | """ 10 | import functools 11 | import time 12 | 13 | from dearpygui import dearpygui as dpg 14 | 15 | from midiexplorer.__config__ import DEBUG 16 | from midiexplorer.midi.timestamp import Timestamp 17 | from midiexplorer.gui.helpers.convert import set_value_preconv 18 | 19 | 20 | @functools.lru_cache() # Only compute once 21 | def get_supported_indicators() -> list: 22 | """Cached list of supported indicators. 23 | 24 | :return: list of indicators. 25 | """ 26 | mon_indicators = [ 27 | 'mon_c', 28 | 'mon_s', 29 | 'mon_note_off', 30 | 'mon_note_on', 31 | 'mon_polytouch', 32 | 'mon_control_change', 33 | 'mon_program_change', 34 | 'mon_aftertouch', 35 | 'mon_pitchwheel', 36 | 'mon_sysex', 37 | 'mon_quarter_frame', 38 | 'mon_songpos', 39 | 'mon_song_select', 40 | 'mon_tune_request', 41 | 'mon_end_of_exclusive', 42 | 'mon_clock', 43 | 'mon_start', 44 | 'mon_continue', 45 | 'mon_stop', 46 | 'mon_active_sensing', 47 | 'mon_reset' 48 | ] 49 | for channel in range(16): 50 | mon_indicators.append(f'mon_{channel}') 51 | for controller in range(128): 52 | mon_indicators.append(f'mon_cc_{controller}') 53 | if DEBUG: # Experimental 54 | mon_indicators.extend([ 55 | 'mon_undef1', 56 | 'mon_undef2', 57 | 'mon_undef3', 58 | 'mon_undef4', 59 | 'mon_all_sound_off', 60 | 'mon_reset_all_controllers', 61 | 'mon_local_control', 62 | 'mon_all_notes_off', 63 | 'mon_omni_off', 64 | 'mon_omni_on', 65 | 'mon_mono_on', 66 | 'mon_poly_on' 67 | ]) 68 | 69 | return mon_indicators 70 | 71 | 72 | def get_supported_decoders() -> list: 73 | decoders = [ 74 | 'pc_num', 75 | 'pc_group_name', 76 | 'pc_name', 77 | 'syx_id_group', 78 | 'syx_id_region', 79 | 'syx_id_name', 80 | 'syx_id_val', 81 | 'syx_device_id', 82 | 'syx_sub_id1_name', 83 | 'syx_sub_id1_val', 84 | 'syx_sub_id2_name', 85 | 'syx_sub_id2_val', 86 | 'syx_payload', 87 | ] 88 | return decoders 89 | 90 | 91 | def get_theme(static, disable: bool = False): 92 | if not static and not disable: 93 | theme = '__act' 94 | elif not static and disable: 95 | theme = None 96 | else: 97 | theme = '__force_act' 98 | return theme 99 | 100 | 101 | def mon(indicator: int | str, static: bool = False) -> None: 102 | """Illuminates an indicator in the monitor panel and prepare metadata for its lifetime management. 103 | 104 | :param indicator: Name of the indicator to blink. 105 | :param static: Live or static mode. 106 | 107 | """ 108 | # logger = midiexplorer.gui.logger.Logger() 109 | # logger.log_debug(f"blink {indicator}") 110 | 111 | now = time.perf_counter() - Timestamp.START_TIME 112 | delay = dpg.get_value('mon_blink_duration') 113 | target = f'mon_{indicator}_active_until' 114 | if not static: 115 | until = now + delay 116 | else: 117 | until = float('inf') 118 | dpg.set_value(target, until) 119 | theme = get_theme(static) 120 | # EOX special case since we have two alternate representations. 121 | if indicator != 'end_of_exclusive': 122 | dpg.bind_item_theme(f'mon_{indicator}', theme) 123 | else: 124 | dpg.bind_item_theme(f'mon_{indicator}_common', theme) 125 | dpg.bind_item_theme(f'mon_{indicator}_syx', theme) 126 | # logger.log_debug(f"Current time:{time.perf_counter() - Timestamp.START_TIME}") 127 | # logger.log_debug(f"Blink {delay} until: {dpg.get_value(target)}") 128 | 129 | 130 | def note_on(number: int | str, static: bool = False, velocity: int = None) -> None: 131 | """Illuminates the note. 132 | 133 | :param number: MIDI note number. 134 | :param static: Live or static mode. 135 | :param velocity: Note velocity 136 | 137 | """ 138 | dpg.enable_item(f'note_{number}') 139 | if velocity is not None: 140 | dpg.set_value(f'note_{number}', velocity) 141 | 142 | 143 | def note_off(number: int | str, static: bool = False) -> None: 144 | """Darken the note. 145 | 146 | :param number: MIDI note number. 147 | :param static: Live or static mode. 148 | 149 | """ 150 | if static: 151 | dpg.enable_item(f'note_{number}') 152 | else: 153 | dpg.disable_item(f'note_{number}') 154 | dpg.set_value(f'note_{number}', 0) 155 | 156 | 157 | def cc(number: int | str, value: int | str, static: bool = False) -> None: 158 | mon(f'cc_{number}', static) 159 | set_value_preconv(f'mon_cc_val_{number}', int(value)) 160 | 161 | def _reset_indicator(indicator): 162 | # EOX is a special case since we have two alternate representations. 163 | if indicator != 'mon_end_of_exclusive': 164 | dpg.bind_item_theme(f'{indicator}', None) 165 | else: 166 | dpg.bind_item_theme(f'{indicator}_common', None) 167 | dpg.bind_item_theme(f'{indicator}_syx', None) 168 | dpg.set_value(f'{indicator}_active_until', 0.0) 169 | 170 | 171 | def update_mon_status() -> None: 172 | """Handles monitor indicators blinking status update each frame. 173 | 174 | Checks for the time it should stay illuminated and darkens it if expired. 175 | 176 | """ 177 | now = time.perf_counter() - Timestamp.START_TIME 178 | for indicator in get_supported_indicators(): 179 | value = dpg.get_value(f'{indicator}_active_until') 180 | if value: # Prevent resetting theme when not needed. 181 | if value < now: 182 | _reset_indicator(indicator) 183 | 184 | 185 | def reset_mon(static: bool = False) -> None: 186 | # FIXME: add a data structure caching the currently lit indicators to only process those needed 187 | for indicator in get_supported_indicators(): 188 | if not static or dpg.get_value(f'{indicator}_active_until') == float('inf'): 189 | _reset_indicator(indicator) 190 | 191 | for note_number in range(0, 128): # All MIDI notes 192 | note_off(note_number, not static) 193 | 194 | if not static: 195 | for decoder in get_supported_decoders(): 196 | dpg.set_value(f'{decoder}', "") 197 | # SysEx dynamic display 198 | dpg.hide_item('syx_decoded_payload') 199 | dpg.show_item('syx_payload_container') 200 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/mon/data.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Monitor data management. 9 | """ 10 | import midi_const 11 | import mido 12 | from dearpygui import dearpygui as dpg 13 | from midi_const import NOTE_OFF_VELOCITY 14 | 15 | from midiexplorer.gui.helpers.convert import set_value_preconv 16 | from midiexplorer.gui.windows.mon.blink import cc, mon, note_off, note_on, \ 17 | reset_mon 18 | from midiexplorer.midi.decoders.sysex import DecodedSysEx, \ 19 | DecodedUniversalSysExPayload 20 | 21 | 22 | def _update_gui_sysex(decoded: DecodedSysEx): 23 | """Populate decoded system exclusive values in the GUI. 24 | 25 | :param decoded: Decoded system exclusive message from _decode_sysex(). 26 | """ 27 | 28 | dpg.set_value('syx_id_group', decoded.identifier.group) 29 | dpg.set_value('syx_id_region', decoded.identifier.region) 30 | dpg.set_value('syx_id_name', decoded.identifier.name) 31 | set_value_preconv('syx_id_val', decoded.identifier.value) 32 | set_value_preconv('syx_device_id', decoded.device_id) 33 | set_value_preconv('syx_payload', decoded.payload.value) 34 | if not len(decoded.payload.value): 35 | dpg.hide_item('syx_payload_container') 36 | else: 37 | dpg.show_item('syx_payload_container') 38 | if isinstance(decoded.payload, DecodedUniversalSysExPayload): 39 | if decoded.payload.sub_id1_value: 40 | dpg.set_value('syx_sub_id1_name', decoded.payload.sub_id1_name) 41 | set_value_preconv('syx_sub_id1_val', decoded.payload.sub_id1_value if not None else "") 42 | dpg.show_item('syx_sub_id1') 43 | else: 44 | dpg.hide_item('syx_sub_id1') 45 | if decoded.payload.sub_id2_value: 46 | dpg.set_value('syx_sub_id2_name', decoded.payload.sub_id2_name) 47 | set_value_preconv('syx_sub_id2_val', decoded.payload.sub_id2_value if not None else "") 48 | dpg.show_item('syx_sub_id2') 49 | else: 50 | dpg.hide_item('syx_sub_id2_value') 51 | dpg.show_item('syx_decoded_payload') 52 | else: 53 | dpg.hide_item('syx_decoded_payload') 54 | 55 | 56 | def update_gui_monitor(data: mido.Message, static: bool = False) -> None: 57 | """Updates the monitor. 58 | 59 | :param data: MIDI data. 60 | :param static: Live or static mode. 61 | 62 | """ 63 | 64 | reset_mon(static=True) # Reset monitor before decoding to avoid keeping old data from selected history row. 65 | 66 | # Status 67 | mon(data.type, static) 68 | 69 | # Channel 70 | if hasattr(data, 'channel'): 71 | mon('c', static) # CHANNEL 72 | mon(data.channel, static) # Channel # 73 | else: 74 | mon('s', static) # SYSTEM 75 | 76 | # Data 1 & 2 77 | if 'note' in data.type: 78 | if dpg.get_value('zero_velocity_note_on_is_note_off') and data.velocity == NOTE_OFF_VELOCITY: 79 | mon('note_off', static) 80 | # Keyboard 81 | if 'on' in data.type and not ( 82 | dpg.get_value('zero_velocity_note_on_is_note_off') and data.velocity == NOTE_OFF_VELOCITY 83 | ): 84 | note_on(data.note, static, data.velocity) 85 | else: 86 | note_off(data.note, static) 87 | elif 'polytouch' == data.type: 88 | # TODO: display 89 | if static: 90 | note_on(data.note, static) 91 | elif 'control_change' == data.type: 92 | cc(data.control, data.value, static) 93 | # TODO: track CC0 & CC32 for bank select detection just before a Program Change message 94 | elif 'program_change' == data.type: 95 | # FIXME: should only be set when both have been received just before the Program Change 96 | bank_select_msb = dpg.get_value('mon_cc_val_0') 97 | bank_select_lsb = dpg.get_value('mon_cc_val_32') 98 | dpg.set_value( 99 | 'pc_bank_num', 100 | int(127 * bank_select_lsb + bank_select_msb) 101 | ) 102 | # FIXME: decode depending on the selected standard 103 | dpg.set_value('pc_bank_name', "TODO") 104 | set_value_preconv('pc_num', data.program) 105 | # Decode General MIDI names. 106 | # FIXME: decode depending on the selected standard 107 | dpg.set_value('pc_group_name', midi_const.GENERAL_MIDI_SOUND_SET_GROUPINGS[data.program]) 108 | dpg.set_value('pc_name', midi_const.GENERAL_MIDI_SOUND_SET[data.program]) 109 | # TODO: Optionally decode other modes names. 110 | elif 'aftertouch' == data.type: 111 | # TODO: display 112 | pass 113 | elif 'pitchwheel' == data.type: 114 | # TODO: display 115 | pass 116 | elif 'sysex' == data.type: 117 | decoded_sysex = DecodedSysEx(data.data) 118 | _update_gui_sysex(decoded_sysex) 119 | elif 'quarter_frame' == data.type: 120 | # TODO: display 121 | pass 122 | elif 'songpos' == data.type: 123 | # TODO: display 124 | pass 125 | elif 'song_select' == data.type: 126 | # TODO: display 127 | pass 128 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/mon/settings.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | """ 7 | Settings options. 8 | """ 9 | 10 | import midiexplorer.midi.notes 11 | 12 | # TODO: add both? 13 | eox_categories = ( 14 | "System Common Message (default, MIDI specification compliant)", 15 | "System Exclusive Message" 16 | ) 17 | notation_modes = { 18 | "English Alphabetical (default)": midiexplorer.midi.notes.MIDI_NOTES_ALPHA_EN, 19 | "Syllabic": midiexplorer.midi.notes.MIDI_NOTES_SYLLABIC, 20 | "German Alphabetic ": midiexplorer.midi.notes.MIDI_NOTES_ALPHA_DE, 21 | } 22 | -------------------------------------------------------------------------------- /src/midiexplorer/gui/windows/smf.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Standard MIDI File (SMF) window and management. 9 | """ 10 | import re 11 | from typing import Any, Optional 12 | 13 | import midi_const 14 | from dearpygui import dearpygui as dpg 15 | from mido import Message, MetaMessage, MidiFile 16 | 17 | from midiexplorer.__config__ import DEBUG 18 | from midiexplorer.gui.helpers import smf 19 | from midiexplorer.gui.helpers.callbacks.debugging import ( 20 | enable as enable_dpg_cb_debugging 21 | ) 22 | 23 | 24 | def create() -> None: 25 | """Creates the SMF window. 26 | 27 | """ 28 | 29 | ### 30 | # Values 31 | ### 32 | with dpg.value_registry(): 33 | dpg.add_string_value(tag="SMF") 34 | 35 | ### 36 | # Window 37 | ### 38 | posx = (1_920 - 1_280) / 2 39 | posy = (1_080 - 720) / 2 40 | width = 1_280 41 | height = 720 42 | with dpg.window( 43 | tag='smf_win', 44 | label="Standard MIDI File", 45 | width=width, 46 | height=height, 47 | no_close=False, 48 | collapsed=False, 49 | pos=[posx, posy], 50 | show=False, 51 | ): 52 | ### 53 | # MENU 54 | ### 55 | smf.create_selectors() 56 | with dpg.menu_bar(): 57 | with dpg.menu(label="File"): 58 | dpg.add_menu_item(label="Open", callback=smf.open_file) 59 | if DEBUG: # TODO: Implement recorder or editor first! 60 | dpg.add_menu_item(label="Save", callback=smf.save_file, enabled=False) 61 | dpg.add_menu_item(label="Save as", callback=smf.save_file_as, enabled=False) 62 | dpg.add_menu_item(tag='smf_close', label="Close", callback=smf.close_file, enabled=False) 63 | 64 | ### 65 | # Content 66 | ### 67 | 68 | ### 69 | # Recoder & Player 70 | # TODO! 71 | ### 72 | 73 | ### 74 | # Analyzer 75 | ### 76 | dpg.add_group(tag='smf_container') 77 | init() 78 | 79 | 80 | def toggle(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 81 | """Callback to toggle the window visibility. 82 | 83 | :param sender: argument is used by DPG to inform the callback 84 | which item triggered the callback by sending the tag 85 | or 0 if trigger by the application. 86 | :param app_data: argument is used DPG to send information to the callback 87 | i.e. the current value of most basic widgets. 88 | :param user_data: argument is Optionally used to pass your own python data into the function. 89 | 90 | """ 91 | if DEBUG: 92 | enable_dpg_cb_debugging(sender, app_data, user_data) 93 | 94 | dpg.configure_item('smf_win', show=not dpg.is_item_visible('smf_win')) 95 | 96 | menu_item = 'menu_tools_smf' 97 | if sender != menu_item: # Update menu checkmark when coming from the shortcut handler 98 | dpg.set_value(menu_item, not dpg.get_value(menu_item)) 99 | 100 | 101 | def init(): 102 | clear() 103 | dpg.disable_item('smf_close') 104 | # TODO: allow drag-dropping a supported file 105 | # TODO: center button 106 | dpg.add_button(label="Open", callback=smf.open_file, parent='smf_container') 107 | 108 | 109 | def clear(): 110 | dpg.delete_item('smf_container', children_only=True) 111 | 112 | 113 | def populate(file_bytes: bytes, midifile: MidiFile): 114 | clear() 115 | dpg.enable_item('smf_close') 116 | parent = 'smf_container' 117 | file_size = len(file_bytes) 118 | 119 | progress = ProgressBar(parent) 120 | 121 | with dpg.group(tag='smf_contents', parent=parent, horizontal=True): 122 | with dpg.group(label='RAW', tag='smf_raw_contents', show=False): 123 | with dpg.table(header_row=True, freeze_rows=1, policy=dpg.mvTable_SizingStretchProp, 124 | borders_innerH=False, borders_outerH=True, 125 | borders_innerV=False, borders_outerV=True, 126 | scrollY=True, width=640): 127 | # Update progress indicator 128 | progress.state("Raw") 129 | 130 | # Header 131 | dpg.add_table_column(label="Offset (hex)") 132 | for index in range(0x00, 0x0F + 1): 133 | dpg.add_table_column(label=f"{index:02X}") 134 | dpg.add_table_column(label="Decoded (ASCII)") 135 | 136 | digits = len(hex(file_size)) - 2 # Remove 0x 137 | for offset in range(0x00, file_size + 1, 0x10): 138 | # Update progress indicator 139 | progress.value((offset / file_size) / 2) 140 | with dpg.table_row(): 141 | dpg.add_text(f"{offset:0{digits}X}") # Offset 142 | chunk = file_bytes[offset:offset + 0x0F + 1] 143 | for index in range(0x00, 0x0F + 1): 144 | try: 145 | dpg.add_selectable( 146 | label=f"{chunk[index]:02X}", 147 | # callback=_selected_hex, # FIXME 148 | user_data=(offset, index) 149 | ) 150 | except IndexError: # We may reach the end of the file earlier than the table width 151 | dpg.add_text() 152 | dotted_ascii = re.sub(r'[^\x32-\x7f]', '.', chunk.decode('ascii', errors='replace')) 153 | dpg.add_text(dotted_ascii) 154 | 155 | # if DEBUG: 156 | # dpg.add_text(f"{file_bytes!r}", wrap=80) 157 | 158 | # Update progress indicator 159 | progress.value(.5) 160 | 161 | with dpg.group(label='Decoded', tag='smf_decoded_contents', show=False): 162 | # Update progress indicator 163 | progress.state("Events") 164 | 165 | with dpg.tree_node(label=f"{midifile.filename}", default_open=True): 166 | 167 | dpg.add_tree_node(label=f"Size: {file_size} bytes", leaf=True) 168 | 169 | with dpg.tree_node(label="Header", default_open=True, selectable=True): 170 | dpg.configure_item( 171 | dpg.last_item(), 172 | # callback=_selected_decode, # FIXME 173 | user_data=range(0, 7), 174 | ) 175 | smf_format = midi_const.SMF_HEADER_FORMATS[midifile.type] 176 | dpg.add_tree_node(label=f"Format: {midifile.type} ({smf_format})", leaf=True, selectable=True) 177 | dpg.add_tree_node(label=f"Number of tracks: {len(midifile.tracks)}", leaf=True, selectable=True) 178 | # FIXME: Upstream: mido. Support SMPTE division format. 179 | dpg.add_tree_node(label=f"Division: {midifile.ticks_per_beat} ticks per quarter-note", 180 | leaf=True, selectable=True) 181 | 182 | tracks_total = len(midifile.tracks) 183 | for i, track in enumerate(midifile.tracks): 184 | # Update progress indicator 185 | progress.value(((i / tracks_total) / 2) + .5) 186 | with dpg.tree_node(label=f"Track #{i} {track.name}", selectable=True): 187 | for j, event in enumerate(track): 188 | if isinstance(event, MetaMessage): 189 | event_type = 'Meta' 190 | # FIXME: Upstream: mido. Support Sysex event type and subtypes. 191 | if isinstance(event, Message): 192 | event_type = 'MIDI' 193 | with dpg.tree_node(label=f"Event #{j} {event_type} {event.type}", selectable=True): 194 | dpg.add_tree_node(label=f"Delta-time: {event.time}", leaf=True, selectable=True) 195 | with dpg.tree_node(label=f"Type: {event.type}", selectable=True): 196 | # TODO: decode 197 | if DEBUG: 198 | dpg.add_tree_node(label=f"{event!r}", leaf=True) 199 | 200 | # if DEBUG: 201 | # dpg.add_text(f"{midifile!r}") 202 | 203 | # Update progress indicator 204 | progress.state("Complete") 205 | progress.value(1.0) 206 | 207 | 208 | def _selected_decode(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 209 | """Generic Dear PyGui callback for debug purposes. 210 | 211 | :param sender: argument is used by DPG to inform the callback 212 | which item triggered the callback by sending the tag 213 | or 0 if trigger by the application. 214 | :param app_data: argument is used by DPG to send information to the callback 215 | i.e. the current value of most basic widgets. 216 | :param user_data: argument is Optionally used to pass your own python data into the function. 217 | 218 | """ 219 | if DEBUG: 220 | enable_dpg_cb_debugging(sender, app_data, user_data) 221 | if app_data: 222 | for index in user_data: 223 | row = int(index / 0x0F) 224 | index = index % 0x0F 225 | # FIXME: highlight file portions when selecting in decoded view 226 | pass 227 | raise NotImplementedError 228 | 229 | 230 | def _selected_hex(sender: int | str, app_data: Any, user_data: Optional[Any]) -> None: 231 | """Generic Dear PyGui callback for debug purposes. 232 | 233 | :param sender: argument is used by DPG to inform the callback 234 | which item triggered the callback by sending the tag 235 | or 0 if trigger by the application. 236 | :param app_data: argument is used by DPG to send information to the callback 237 | i.e. the current value of most basic widgets. 238 | :param user_data: argument is Optionally used to pass your own python data into the function. 239 | 240 | """ 241 | if DEBUG: 242 | enable_dpg_cb_debugging(sender, app_data, user_data) 243 | if app_data: 244 | offset, index = user_data 245 | # FIXME: highlight decoded portions when selecting in the file view 246 | pass 247 | raise NotImplementedError 248 | 249 | 250 | class ProgressBar: 251 | def __init__(self, parent): 252 | self._value: float = 0.0 253 | self._human_readable: str = "0 %" 254 | self._overlay_suffix: str = "(Starting)" 255 | 256 | with dpg.tree_node(label="Analysis in progress...", parent=parent, default_open=True): 257 | self._container = dpg.last_item() 258 | self._progress_bar: int = dpg.add_progress_bar(default_value=0.0, 259 | parent=self._container) 260 | self._update_progress() 261 | 262 | def value(self, value: float) -> None: 263 | self._value = value 264 | self._human_readable: str = f"{round(value * 100)}%" 265 | self._update_progress() 266 | if value == .5: 267 | dpg.show_item('smf_raw_contents') 268 | if value == 1.0: 269 | dpg.show_item('smf_decoded_contents') 270 | dpg.configure_item(self._container, label="Analysis complete!", bullet=True) 271 | dpg.set_value(self._container, False) 272 | 273 | def state(self, value: str) -> None: 274 | self._overlay_suffix = f"({value})" 275 | self._update_progress() 276 | 277 | def _update_progress(self) -> None: 278 | dpg.set_value(self._progress_bar, self._value) 279 | dpg.configure_item(self._progress_bar, 280 | overlay=f"{self._human_readable} {self._overlay_suffix}") 281 | -------------------------------------------------------------------------------- /src/midiexplorer/icons/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/__init__.py -------------------------------------------------------------------------------- /src/midiexplorer/icons/gplv3-or-later-sm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/gplv3-or-later-sm.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/gplv3-or-later.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/gplv3-or-later.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer.ico -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MIDI Explorer Logo 23 | 45 | 47 | 86 | 134 | 142 | MIDI Explorer Logo2022-07-12Raphaël DoursenaudCreative Commons Attribution-Share Alike 4.0 Internationalhttps://commons.wikimedia.org/wiki/File:MIDI_connector2.svg, https://commons.wikimedia.org/wiki/File:Steering_Wheel_Black.svghttps://commons.wikimedia.org/wiki/User:Fred_the_Oyster 146 | https://commons.wikimedia.org/wiki/User:SpiderLogo for https://github.com/ematech/midiexplorer 149 | 151 | 153 | 155 | 157 | 159 | -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_128.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_16.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_256.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_32.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_48.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_512.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_64.png -------------------------------------------------------------------------------- /src/midiexplorer/icons/midiexplorer_96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EMATech/MidiExplorer/27f39ba24351a66d3af2ac613fd4516e91bfa585/src/midiexplorer/icons/midiexplorer_96.png -------------------------------------------------------------------------------- /src/midiexplorer/midi/MIDI Implementation Chart v1.0 Sample.rst: -------------------------------------------------------------------------------- 1 | .. 2 | MIDI Implementation Chart v1.0 3 | Reference: MIDI 1.0 Detailed Specification v4.2.1 4 | 5 | TODO: write a generator? 6 | 7 | +------------------------------+---------------------------------------------------------+-----------------------------+ 8 | | [Designation] | | Date: | 9 | +------------------------------+ +-----------------------------+ 10 | | [Model] | MIDI Implementation Chart | Version: | 11 | +------------------------------+----------------------------+----------------------------+-----------------------------+ 12 | | Function... | Transmitted | Recognized | Remarks | 13 | +=============+================+============================+============================+=============================+ 14 | | Basic | Default | | | | 15 | | Channel +----------------+----------------------------+----------------------------+-----------------------------+ 16 | | | Changed | | | | 17 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 18 | | Mode | Default | | | | 19 | | +----------------+----------------------------+----------------------------+-----------------------------+ 20 | | | Messages | | | | 21 | | +----------------+----------------------------+----------------------------+-----------------------------+ 22 | | | Altered | \************************* | | | 23 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 24 | | Note Number | True voice | \************************* | | | 25 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 26 | | Velocity | Note On | | | | 27 | | +----------------+----------------------------+----------------------------+-----------------------------+ 28 | | | Note Off | | | | 29 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 30 | | After | Key's | | | | 31 | | Touch +----------------+----------------------------+----------------------------+-----------------------------+ 32 | | | Channel | | | | 33 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 34 | | Pitch Bend | | | | 35 | +------------------------------+----------------------------+----------------------------+-----------------------------+ 36 | | Control | | | | 37 | | Change | | | | 38 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 39 | | Program | | | | | 40 | | Change +----------------+----------------------------+----------------------------+-----------------------------+ 41 | | | True Number | \************************* | | | 42 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 43 | | System Exclusive | | | | 44 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 45 | | System | Song Position | | | | 46 | | Common +----------------+----------------------------+----------------------------+-----------------------------+ 47 | | | Song Select | | | | 48 | | +----------------+----------------------------+----------------------------+-----------------------------+ 49 | | | Tune Request | | | | 50 | +-------------+---------------------------------------------+----------------------------+-----------------------------+ 51 | | System | Clock | | | | 52 | | Real Time +----------------+----------------------------+----------------------------+-----------------------------+ 53 | | | Commands | | | | 54 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 55 | | Aux | Local On/Off | | | | 56 | | Messages +----------------+----------------------------+----------------------------+-----------------------------+ 57 | | | All Notes Off | | | | 58 | | +----------------+----------------------------+----------------------------+-----------------------------+ 59 | | | Active Sensing | | | | 60 | | +----------------+----------------------------+----------------------------+-----------------------------+ 61 | | | System Reset | | | | 62 | +-------------+----------------+----------------------------+----------------------------+-----------------------------+ 63 | | Notes | | | | 64 | +------------------------------+----------------------------+----------------------------+-----------------------------+ 65 | | Mode 1: Omni On, Poly | Mode 2: Omni On, Mono | | o: Yes | 66 | +------------------------------+----------------------------+----------------------------+-----------------------------+ 67 | | Mode 3: Omni Off, Poly | Mode 4 Omni Off, Mono | | x: No | 68 | +------------------------------+----------------------------+----------------------------+-----------------------------+ 69 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/MIDI Implementation Chart v2.0 Sample.rst: -------------------------------------------------------------------------------- 1 | .. 2 | MIDI Implementation Chart v2.0 3 | Reference: RP-028 4 | 5 | TODO: write a generator? 6 | 7 | +----------------------------------------------------------------------------------------------------------------------+ 8 | | MIDI Implementation Chart v2.0 | 9 | +----------------------------------------------------------------------------------------------------------------------+ 10 | | | 11 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 12 | | Manufacturer: | Model: | Version: | Date: | 13 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 14 | | | Transmit/Export | Recognize/Import | Remarks | 15 | +=============================================+=================+==================+===================================+ 16 | | 1. Basic Information | | | | 17 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 18 | | MIDI Channels | | | | 19 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 20 | | Note numbers | | | | 21 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 22 | | Program change | | | | 23 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 24 | | Bank Select response? | | | [List banks utilized if yes] | 25 | +------------------+--------------------------+-----------------+------------------+-----------------------------------+ 26 | | Modes supported: | Mode 1: Omni-On, Poly | | | | 27 | | +--------------------------+-----------------+------------------+-----------------------------------+ 28 | | | Mode 2: Omni-On, Mono | | | | 29 | | +--------------------------+-----------------+------------------+-----------------------------------+ 30 | | | Mode 3: Omni-Off, Poly | | | | 31 | | +--------------------------+-----------------+------------------+-----------------------------------+ 32 | | | Mode 4: Omni-Off, Mono | | | | 33 | +------------------+--------------------------+-----------------+------------------+-----------------------------------+ 34 | | Note-On Velocity | | | | 35 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 36 | | Note-Off Velocity | | | | 37 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 38 | | Channel Aftertouch | | | | 39 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 40 | | Poly (Key) Aftertouch | | | | 41 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 42 | | Pitch Bend | | | | 43 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 44 | | Active Sensing | | | | 45 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 46 | | System Reset | | | | 47 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 48 | | Tune Request | | | | 49 | +------------------+--------------------------+-----------------+------------------+-----------------------------------+ 50 | | Universal System | Sample Dump Standard | | | | 51 | | Exclusive: +--------------------------+-----------------+------------------+-----------------------------------+ 52 | | | Device Inquiry | | | | 53 | | +--------------------------+-----------------+------------------+-----------------------------------+ 54 | | | File Dump | | | | 55 | | +--------------------------+-----------------+------------------+-----------------------------------+ 56 | | | MIDI Tuning | | | | 57 | | +--------------------------+-----------------+------------------+-----------------------------------+ 58 | | | Master Volume | | | | 59 | | +--------------------------+-----------------+------------------+-----------------------------------+ 60 | | | Master Balance | | | | 61 | | +--------------------------+-----------------+------------------+-----------------------------------+ 62 | | | Notation Information | | | | 63 | | +--------------------------+-----------------+------------------+-----------------------------------+ 64 | | | Turn GM1 System On | | | | 65 | | +--------------------------+-----------------+------------------+-----------------------------------+ 66 | | | Turn GM2 System On | | | | 67 | | +--------------------------+-----------------+------------------+-----------------------------------+ 68 | | | Turn GM System Off | | | | 69 | | +--------------------------+-----------------+------------------+-----------------------------------+ 70 | | | DLS-1 | | | | 71 | | +--------------------------+-----------------+------------------+-----------------------------------+ 72 | | | File Reference | | | | 73 | | +--------------------------+-----------------+------------------+-----------------------------------+ 74 | | | Controller Destination | | | | 75 | | +--------------------------+-----------------+------------------+-----------------------------------+ 76 | | | Key-based Instrument Ctrl| | | | 77 | | +--------------------------+-----------------+------------------+-----------------------------------+ 78 | | | Master Fine/Coarse Tune | | | | 79 | | +--------------------------+-----------------+------------------+-----------------------------------+ 80 | | | Other Universal | | | | 81 | | | System Exclusive | | | | 82 | +------------------+--------------------------+-----------------+------------------+-----------------------------------+ 83 | | Manufacturer or Non-Commercial | | | | 84 | | System Exclusive | | | | 85 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 86 | | NRPNs | | | | 87 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 88 | | RPN 00 | | | | 89 | | (Pitch Bend Sensitivity) | | | | 90 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 91 | | RPN 01 | | | | 92 | | (Channel Fine Tune) | | | | 93 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 94 | | RPN 02 | | | | 95 | | (Channel Coarse Tune) | | | | 96 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 97 | | RPN 03 | | | | 98 | | (Tuning Program Select) | | | | 99 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 100 | | RPN 04 | | | | 101 | | (Tuning Bank Select) | | | | 102 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 103 | | RPN 05 | | | | 104 | | (Modulation Depth Range) | | | | 105 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 106 | | 2. MIDI Timing and Synchronization | | | | 107 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 108 | | MIDI Clock | | | | 109 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 110 | | Song Position Pointer | | | | 111 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 112 | | Song Select | | | | 113 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 114 | | Start | | | | 115 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 116 | | Continue | | | | 117 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 118 | | Stop | | | | 119 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 120 | | MIDI Time Code | | | | 121 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 122 | | MIDI Machine Control | | | | 123 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 124 | | MIDI Show Control | | | | 125 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 126 | | 3. Extensions Compatibility | | | | 127 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 128 | | General MIDI compatible? | | | | 129 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 130 | | Is GM default power-up mode? | | | | 131 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 132 | | DLS compatible? | | | | 133 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 134 | | Standard MIDI Files | | | | 135 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 136 | | XMF Files | | | | 137 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 138 | | SP-MIDI compatible? | | | | 139 | +---------------------------------------------+-----------------+------------------+-----------------------------------+ 140 | 141 | 142 | 143 | .. list-table:: Control Number Information 144 | :widths: 5 40 10 10 35 145 | :header-rows: 1 146 | 147 | * - Control # 148 | - Function 149 | - Transmitted 150 | - Recognized 151 | - Remarks 152 | * - 0 153 | - Bank Select (MSB) 154 | - 155 | - 156 | - 157 | * - 1 158 | - Modulation Wheel (MSB) 159 | - 160 | - 161 | - 162 | * - 2 163 | - Breath Controller (MSB) 164 | - 165 | - 166 | - 167 | * - 3 168 | - 169 | - 170 | - 171 | - 172 | * - 4 173 | - Foot Controller (MSB) 174 | - 175 | - 176 | - 177 | * - 5 178 | - Portamento Time (MSB) 179 | - 180 | - 181 | - 182 | * - 6 183 | - Data Entry (MSB) 184 | - 185 | - 186 | - 187 | * - 7 188 | - Channel Volume (MSB) 189 | - 190 | - 191 | - 192 | * - 8 193 | - Balance (MSB) 194 | - 195 | - 196 | - 197 | * - 9 198 | - 199 | - 200 | - 201 | - 202 | * - 10 203 | - Pan (MSB) 204 | - 205 | - 206 | - 207 | * - 11 208 | - Expression (MSB) 209 | - 210 | - 211 | - 212 | * - 12 213 | - Effect Control 1 (MSB) 214 | - 215 | - 216 | - 217 | * - 13 218 | - Effect Control 2 (MSB) 219 | - 220 | - 221 | - 222 | * - 14 223 | - 224 | - 225 | - 226 | - 227 | * - 15 228 | - 229 | - 230 | - 231 | - 232 | * - 16 233 | - General Purpose Controller 1 (MSB) 234 | - 235 | - 236 | - 237 | * - 17 238 | - General Purpose Controller 2 (MSB) 239 | - 240 | - 241 | - 242 | * - 18 243 | - General Purpose Controller 3 (MSB) 244 | - 245 | - 246 | - 247 | * - 19 248 | - General Purpose Controller 4 (MSB) 249 | - 250 | - 251 | - 252 | * - 20 253 | - 254 | - 255 | - 256 | - 257 | * - 21 258 | - 259 | - 260 | - 261 | - 262 | * - 22 263 | - 264 | - 265 | - 266 | - 267 | * - 23 268 | - 269 | - 270 | - 271 | - 272 | * - 24 273 | - 274 | - 275 | - 276 | - 277 | * - 25 278 | - 279 | - 280 | - 281 | - 282 | * - 26 283 | - 284 | - 285 | - 286 | - 287 | * - 27 288 | - 289 | - 290 | - 291 | - 292 | * - 28 293 | - 294 | - 295 | - 296 | - 297 | * - 29 298 | - 299 | - 300 | - 301 | - 302 | * - 30 303 | - 304 | - 305 | - 306 | - 307 | * - 31 308 | - 309 | - 310 | - 311 | - 312 | * - 32 313 | - Bank Select (LSB) 314 | - 315 | - 316 | - 317 | * - 33 318 | - Modulation Wheel (LSB) 319 | - 320 | - 321 | - 322 | * - 34 323 | - Breath Controller (LSB) 324 | - 325 | - 326 | - 327 | * - 35 328 | - 329 | - 330 | - 331 | - 332 | * - 36 333 | - Foot Controller (LSB) 334 | - 335 | - 336 | - 337 | * - 37 338 | - Portamento Time (LSB) 339 | - 340 | - 341 | - 342 | * - 38 343 | - Data Entry (LSB) 344 | - 345 | - 346 | - 347 | * - 39 348 | - Channel Volume (LSB) 349 | - 350 | - 351 | - 352 | * - 40 353 | - Balance (LSB) 354 | - 355 | - 356 | - 357 | * - 41 358 | - 359 | - 360 | - 361 | - 362 | * - 42 363 | - Pan (LSB) 364 | - 365 | - 366 | - 367 | * - 43 368 | - Expression (LSB) 369 | - 370 | - 371 | - 372 | * - 44 373 | - Effect Control 1 (LSB) 374 | - 375 | - 376 | - 377 | * - 45 378 | - Effect Control 2 (LSB) 379 | - 380 | - 381 | - 382 | * - 46 383 | - 384 | - 385 | - 386 | - 387 | * - 47 388 | - 389 | - 390 | - 391 | - 392 | * - 48 393 | - General Purpose Controller 1 (LSB) 394 | - 395 | - 396 | - 397 | * - 49 398 | - General Purpose Controller 2 (LSB) 399 | - 400 | - 401 | - 402 | * - 50 403 | - General Purpose Controller 3 (LSB) 404 | - 405 | - 406 | - 407 | * - 51 408 | - General Purpose Controller 4 (LSB) 409 | - 410 | - 411 | - 412 | * - 52 413 | - 414 | - 415 | - 416 | - 417 | * - 53 418 | - 419 | - 420 | - 421 | - 422 | * - 54 423 | - 424 | - 425 | - 426 | - 427 | * - 55 428 | - 429 | - 430 | - 431 | - 432 | * - 56 433 | - 434 | - 435 | - 436 | - 437 | * - 57 438 | - 439 | - 440 | - 441 | - 442 | * - 58 443 | - 444 | - 445 | - 446 | - 447 | * - 59 448 | - 449 | - 450 | - 451 | - 452 | * - 60 453 | - 454 | - 455 | - 456 | - 457 | * - 61 458 | - 459 | - 460 | - 461 | - 462 | * - 62 463 | - 464 | - 465 | - 466 | - 467 | * - 63 468 | - 469 | - 470 | - 471 | - 472 | * - 64 473 | - Sustain Pedal 474 | - 475 | - 476 | - 477 | * - 65 478 | - Portamento On/Off 479 | - 480 | - 481 | - 482 | * - 66 483 | - Sostenuto 484 | - 485 | - 486 | - 487 | * - 67 488 | - Soft Pedal 489 | - 490 | - 491 | - 492 | * - 68 493 | - Legato Footswitch 494 | - 495 | - 496 | - 497 | * - 69 498 | - Hold 2 499 | - 500 | - 501 | - 502 | * - 70 503 | - Sound Controller 1 (default: Sound Variation) 504 | - 505 | - 506 | - 507 | * - 71 508 | - Sound Controller 2 (default: Timbre / Harmonic Quality) 509 | - 510 | - 511 | - 512 | * - 72 513 | - Sound Controller 3 (default: Release Time) 514 | - 515 | - 516 | - 517 | * - 73 518 | - Sound Controller 4 (default: Attack Time) 519 | - 520 | - 521 | - 522 | * - 74 523 | - Sound Controller 5 (default: Brightness) 524 | - 525 | - 526 | - 527 | * - 75 528 | - Sound Controller 6 (GM2 default: Decay Time) 529 | - 530 | - 531 | - 532 | * - 76 533 | - Sound Controller 7 (GM2 default: Vibrato Rate) 534 | - 535 | - 536 | - 537 | * - 77 538 | - Sound Controller 8 (GM2 default Vibrato Depth) 539 | - 540 | - 541 | - 542 | * - 78 543 | - Sound Controller 9 (GM2 default: Vibrato Delay) 544 | - 545 | - 546 | - 547 | * - 79 548 | - Sound Controller 10 (GM2 default: Undefined) 549 | - 550 | - 551 | - 552 | * - 80 553 | - General Purpose Controller 5 554 | - 555 | - 556 | - 557 | * - 81 558 | - General Purpose Controller 6 559 | - 560 | - 561 | - 562 | * - 82 563 | - General Purpose Controller 7 564 | - 565 | - 566 | - 567 | * - 83 568 | - General Purpose Controller 8 569 | - 570 | - 571 | - 572 | * - 84 573 | - Portamento Control 574 | - 575 | - 576 | - 577 | * - 85 578 | - 579 | - 580 | - 581 | - 582 | * - 86 583 | - 584 | - 585 | - 586 | - 587 | * - 87 588 | - 589 | - 590 | - 591 | - 592 | * - 88 593 | - 594 | - 595 | - 596 | - 597 | * - 89 598 | - 599 | - 600 | - 601 | - 602 | * - 90 603 | - 604 | - 605 | - 606 | - 607 | * - 91 608 | - Effects 1 Depth (default: Reverb Send) 609 | - 610 | - 611 | - 612 | * - 92 613 | - Effects 2 Depth (default: Tremolo Depth) 614 | - 615 | - 616 | - 617 | * - 93 618 | - Effects 3 Depth (default: Chorus Send) 619 | - 620 | - 621 | - 622 | * - 94 623 | - Effects 4 Depth (default: Celeste [Detune] Depth) 624 | - 625 | - 626 | - 627 | * - 95 628 | - Effects 5 Depth (default: Phaser Depth) 629 | - 630 | - 631 | - 632 | * - 96 633 | - Data Increment 634 | - 635 | - 636 | - 637 | * - 97 638 | - Data Decrement 639 | - 640 | - 641 | - 642 | * - 98 643 | - Non-Registered Parameter Number (LSB) 644 | - 645 | - 646 | - 647 | * - 99 648 | - Non-Registered Parameter Number (MSB) 649 | - 650 | - 651 | - 652 | * - 100 653 | - Registered Parameter Number (LSB) 654 | - 655 | - 656 | - 657 | * - 101 658 | - Registered Parameter Number (MSB) 659 | - 660 | - 661 | - 662 | * - 102 663 | - 664 | - 665 | - 666 | - 667 | * - 103 668 | - 669 | - 670 | - 671 | - 672 | * - 104 673 | - 674 | - 675 | - 676 | - 677 | * - 105 678 | - 679 | - 680 | - 681 | - 682 | * - 106 683 | - 684 | - 685 | - 686 | - 687 | * - 107 688 | - 689 | - 690 | - 691 | - 692 | * - 108 693 | - 694 | - 695 | - 696 | - 697 | * - 109 698 | - 699 | - 700 | - 701 | - 702 | * - 110 703 | - 704 | - 705 | - 706 | - 707 | * - 111 708 | - 709 | - 710 | - 711 | - 712 | * - 112 713 | - 714 | - 715 | - 716 | - 717 | * - 113 718 | - 719 | - 720 | - 721 | - 722 | * - 114 723 | - 724 | - 725 | - 726 | - 727 | * - 115 728 | - 729 | - 730 | - 731 | - 732 | * - 116 733 | - 734 | - 735 | - 736 | - 737 | * - 117 738 | - 739 | - 740 | - 741 | - 742 | * - 118 743 | - 744 | - 745 | - 746 | - 747 | * - 119 748 | - 749 | - 750 | - 751 | - 752 | * - 120 753 | - All Sound Off 754 | - 755 | - 756 | - 757 | * - 121 758 | - Reset All Controllers 759 | - 760 | - 761 | - 762 | * - 122 763 | - Local Control On/Off 764 | - 765 | - 766 | - 767 | * - 123 768 | - All Notes Off 769 | - 770 | - 771 | - 772 | * - 124 773 | - Omni Mode Off 774 | - 775 | - 776 | - 777 | * - 125 778 | - Omni Mode On 779 | - 780 | - 781 | - 782 | * - 126 783 | - Poly Mode Off 784 | - 785 | - 786 | - 787 | * - 127 788 | - Poly Mode On 789 | - 790 | - 791 | - 792 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | MIDI helpers. 9 | """ 10 | 11 | import mido # https://mido.readthedocs.io/en/latest/ 12 | 13 | from midiexplorer.gui.helpers.logger import Logger 14 | 15 | 16 | def init() -> None: 17 | """Initializes MIDO with the RtMidi backend. 18 | 19 | This doesn't open any input or output at this stage. 20 | 21 | """ 22 | logger = Logger() 23 | 24 | # RtMidi is required for the features we need (callback and delta timestamps) 25 | mido.set_backend('mido.backends.rtmidi') 26 | 27 | # ------------------------- 28 | # MIDO and backend versions 29 | # ------------------------- 30 | logger.log_debug("Using MIDO:") 31 | logger.log_debug(f"\t - version: {mido.version_info}") 32 | logger.log_debug(f"\t - backend: {mido.backend.name}") 33 | 34 | # ------------------------- 35 | # Native API used by RtMidi 36 | # ------------------------- 37 | if mido.backend.name == 'mido.backends.rtmidi': 38 | api_names = mido.backend.module.get_api_names() 39 | api_names_count = len(api_names) 40 | if api_names_count == 0: 41 | logger.log_warning("No RtMidi API found!") 42 | elif api_names_count == 1: 43 | logger.log_debug(f"\t - RtMidi API: {api_names[0]}") 44 | else: 45 | logger.log_debug("\t - RtMidi APIs:") 46 | for name in api_names: 47 | logger.log_debug(f"\t\t - {name}") 48 | else: 49 | err_msg = "Wrong MIDI backend or no backend loaded!" 50 | logger.log_warning(err_msg) 51 | raise ValueError(err_msg) 52 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/decoders/__init__.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | MIDI decoders. 9 | """ 10 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/decoders/sysex.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | System Exclusive Decoders. 9 | """ 10 | 11 | # TODO: separate into dedicated sub decoders? 12 | 13 | # TODO: decode sample dump standard (page 35) 14 | # ACK, NAK, Wait, Cancel & EOF 15 | # TODO: decode device inquiry (page 40) 16 | # TODO: decode file dump (page 41) 17 | # TODO: decode midi tuning (page 47) 18 | # TODO: decode general midi system messages (page 52) 19 | # TODO: decode MTC full message, user bits and real time cueing (page 53 + dedicated spec) 20 | # TODO: decode midi show control (page 53 + dedicated spec) 21 | # TODO: decode notation information (page 54) 22 | # TODO: decode device control (page 57) 23 | # TODO: decode MMC (page 58 + dedicated spec) 24 | 25 | import functools 26 | from collections import deque 27 | 28 | import midi_const 29 | import mido 30 | 31 | from midiexplorer.gui import Logger 32 | 33 | 34 | class DecodedSysExId: 35 | """ 36 | System exclusive ID decoder. 37 | 38 | Denoted "ID number" in the specification. 39 | Frequently referred to as "Manufacturer ID". 40 | """ 41 | 42 | def __init__(self, value: int | tuple[int]): 43 | length: int 44 | try: 45 | length = len(value) 46 | except TypeError: 47 | # Integers don't have length 48 | length = 1 49 | pass 50 | if length not in (1, 3): 51 | raise ValueError(f"A system exclusive ID can only be 1-byte or 3-bytes long, not {length}-bytes!") 52 | self._len = length 53 | if isinstance(value, int): 54 | if value == 0: 55 | raise ValueError("3-bytes must be provided when the first is 0x00.") 56 | self._raw = value 57 | 58 | @property 59 | def length(self) -> int: 60 | return self._len 61 | 62 | @property 63 | def value(self) -> int | tuple[int]: 64 | return self._raw 65 | 66 | @functools.cached_property 67 | def group(self) -> str: 68 | index: int 69 | group: str 70 | if self._len == 1: 71 | index = self._raw 72 | else: 73 | index = self._raw[0] 74 | group = midi_const.SYSTEM_EXCLUSIVE_ID_GROUPS.get(index, "Undefined") 75 | return group 76 | 77 | @functools.cached_property 78 | def region(self) -> str: 79 | index: int 80 | region: str 81 | if self._len == 1: 82 | index = self._raw 83 | else: 84 | index = self._raw[1] 85 | region = midi_const.SYSTEM_EXCLUSIVE_ID_REGIONS.get(index, "N/A") 86 | return region 87 | 88 | @functools.cached_property 89 | def name(self) -> str: 90 | name: str = "Undefined" 91 | if self._len == 1: 92 | name = midi_const.SYSTEM_EXCLUSIVE_ID.get(self._raw, "Undefined") 93 | else: 94 | name = midi_const.SYSTEM_EXCLUSIVE_ID.get( 95 | self._raw[0], {} 96 | ).get( 97 | self._raw[1], {} 98 | ).get( 99 | self._raw[2], name 100 | ) 101 | return name 102 | 103 | 104 | class DecodedSysExPayload: 105 | _id = int 106 | _raw: deque 107 | 108 | def __init__(self, identifier: DecodedSysExId, contents: int | tuple[int]): 109 | self._id = identifier 110 | if isinstance(contents, int): 111 | self._raw = deque([contents]) 112 | else: 113 | self._raw = deque(contents) 114 | 115 | @property 116 | def value(self): 117 | return tuple(self._raw) 118 | 119 | @staticmethod 120 | def get_decoder(identifier): 121 | if midi_const.SYSTEM_EXCLUSIVE_ID.get(identifier.value) == "Non-Real Time": 122 | return DecodedUniversalNonRealTimeSysExPayload 123 | if midi_const.SYSTEM_EXCLUSIVE_ID.get(identifier.value) == "Real Time": 124 | return DecodedUniversalRealTimeSysExPayload 125 | return DecodedSysExPayload 126 | 127 | 128 | class DecodedUniversalSysExPayload(DecodedSysExPayload): 129 | def __init__(self, identifier: DecodedSysExId, contents: int | tuple[int]): 130 | super().__init__(identifier, contents) 131 | 132 | 133 | class DecodedUniversalNonRealTimeSysExPayload(DecodedUniversalSysExPayload): 134 | def __init__(self, identifier: DecodedSysExId, contents: int | tuple[int]): 135 | if midi_const.SYSTEM_EXCLUSIVE_ID.get(identifier.value) != "Non-Real Time": 136 | raise ValueError 137 | super().__init__(identifier, contents) 138 | self.sub_id1_value = self._raw.popleft() 139 | self.sub_id1_name = \ 140 | midi_const.DEFINED_UNIVERSAL_SYSTEM_EXCLUSIVE_MESSAGES_NON_REAL_TIME_SUB_ID_1.get( 141 | self.sub_id1_value, "Undefined" 142 | ) 143 | if self.sub_id1_value in midi_const.NON_REAL_TIME_SUB_ID_2_FROM_1: 144 | self.sub_id2_value = self._raw.popleft() 145 | self.sub_id2_name = midi_const.NON_REAL_TIME_SUB_ID_2_FROM_1.get( 146 | self.sub_id1_value 147 | ).get( 148 | self.sub_id2_value, "Undefined" 149 | ) 150 | 151 | 152 | class DecodedUniversalRealTimeSysExPayload(DecodedUniversalSysExPayload): 153 | def __init__(self, identifier: DecodedSysExId, contents: int | tuple[int]): 154 | if midi_const.SYSTEM_EXCLUSIVE_ID.get(identifier.value) != "Real Time": 155 | raise ValueError 156 | super().__init__(identifier, contents) 157 | self.sub_id1_value = self._raw.popleft() 158 | self.sub_id1_name = \ 159 | midi_const.DEFINED_UNIVERSAL_SYSTEM_EXCLUSIVE_MESSAGES_REAL_TIME_SUB_ID_1.get( 160 | self.sub_id1_value, "Undefined" 161 | ) 162 | if self.sub_id1_value in midi_const.REAL_TIME_SUB_ID_2_FROM_1: 163 | self.sub_id2_value = self._raw.popleft() 164 | self.sub_id2_name = midi_const.REAL_TIME_SUB_ID_2_FROM_1.get( 165 | self.sub_id1_value 166 | ).get( 167 | self.sub_id2_value, "Undefined" 168 | ) 169 | 170 | 171 | class DecodedSysEx: 172 | def __init__(self, message: tuple): 173 | if len(message) < 3: 174 | # We need at least 3 bytes: 175 | # - 1 ID number byte 176 | # - 1 device ID byte 177 | # - 1 payload byte 178 | raise ValueError("Message too short (less than 3 bytes) to be a proper system exclusive message.") 179 | 180 | # Scrub EOX if present 181 | if message[-1] == mido.messages.specs.SYSEX_END: 182 | Logger().log_warning("Scrubbing EOX from SysEx payload before decoding!") 183 | self._raw = message[:-1] 184 | else: 185 | self._raw = message 186 | 187 | # Determine ID length 188 | if self._raw[0] == 0x00: 189 | # 3-byte ID 190 | if len(self._raw) < 5: 191 | # We need at least 5 bytes: 192 | # - 3 ID number bytes 193 | # - 1 device ID byte 194 | # - 1 payload byte 195 | raise ValueError( 196 | "Message too short (less than 5 bytes) to be a proper system exclusive message with a 3-byte ID." 197 | ) 198 | self._device_id_byte = 3 199 | self.identifier = DecodedSysExId(self._raw[0:self._device_id_byte]) 200 | else: 201 | # 1-byte ID 202 | self._device_id_byte = 1 203 | self.identifier = DecodedSysExId(self._raw[0]) 204 | 205 | @functools.cached_property 206 | def device_id(self) -> int: 207 | return self._raw[self._device_id_byte] 208 | 209 | @functools.cached_property 210 | def _payload(self) -> int | tuple[int]: 211 | return self._raw[self._device_id_byte + 1:] 212 | 213 | @functools.cached_property 214 | def payload( 215 | self 216 | ) -> DecodedSysExPayload | DecodedUniversalRealTimeSysExPayload | DecodedUniversalNonRealTimeSysExPayload: 217 | decoder = DecodedSysExPayload.get_decoder(self.identifier) 218 | return decoder(self.identifier, self._payload) 219 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/mido2standard.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Helper to convert mido status message types to standard midi numbers. 9 | """ 10 | import mido.messages 11 | 12 | 13 | def get_status_by_type(msg_type: str) -> int: 14 | """Converts mido message type name to MIDI status number. 15 | 16 | :param msg_type: mido message type. 17 | :return: MIDI status number. 18 | 19 | """ 20 | return mido.messages.SPEC_BY_TYPE[msg_type]['status_byte'] 21 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/notes.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | Helper to convert MIDI notes to various human-readable formats. 9 | """ 10 | 11 | NOTES_SYLLABIC = ["Do", "Do#", "Re", "Re#", "Mi", "Fa", "Fa#", "Sol", "Sol#", "La", "La#", "Si"] 12 | NOTES_ALPHA_EN = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] 13 | NOTES_ALPHA_DE = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "H"] 14 | 15 | MIDI_NOTES_SYLLABIC = {} 16 | MIDI_NOTES_ALPHA_EN = {} 17 | MIDI_NOTES_ALPHA_DE = {} 18 | 19 | for midi_note in range(128): 20 | note_index = int(midi_note % 12) 21 | # TODO: allow customization because standards don’t seem to agree on a single definition 22 | octave = int(midi_note / 12) - 1 23 | MIDI_NOTES_SYLLABIC[midi_note] = f"{NOTES_SYLLABIC[note_index]}{octave}" 24 | MIDI_NOTES_ALPHA_EN[midi_note] = f"{NOTES_ALPHA_EN[note_index]}{octave}" 25 | MIDI_NOTES_ALPHA_DE[midi_note] = f"{NOTES_ALPHA_DE[note_index]}{octave}" 26 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/ports.py: -------------------------------------------------------------------------------- 1 | # This Python file uses the following encoding: utf-8 2 | # 3 | # SPDX-FileCopyrightText: 2021-2022 Raphaël Doursenaud 4 | # 5 | # SPDX-License-Identifier: GPL-3.0-or-later 6 | 7 | """ 8 | MIDI ports helpers. 9 | """ 10 | 11 | import multiprocessing 12 | import platform 13 | import threading 14 | from abc import ABC 15 | from functools import cached_property 16 | 17 | import mido 18 | 19 | from midiexplorer.gui.helpers.logger import Logger 20 | from midiexplorer.midi.timestamp import Timestamp 21 | 22 | # TODO: MIDI Input Queue Singleton? 23 | midi_in_lock = threading.Lock() 24 | midi_in_queue = multiprocessing.SimpleQueue() 25 | 26 | 27 | class MidiPort(ABC): 28 | """Abstract Base Class for MIDI ports management around Mido. 29 | 30 | """ 31 | _system = platform.system() 32 | 33 | port: mido.ports.BasePort 34 | 35 | def __init__(self, name: str) -> None: 36 | self.name = name 37 | 38 | def __repr__(self) -> str: 39 | return self.name 40 | 41 | @cached_property 42 | def num(self) -> str: 43 | """Numerical ID of the port. 44 | 45 | Platform dependant: 46 | - Microsoft Windows (MME): single integer number 47 | - Linux (ALSA): "x:y" pair of integer numbers 48 | - Mac OS X (Core MIDI): seem to not have any ID exposed (at least by RtMidi) 49 | 50 | :return: The system port index. 51 | 52 | """ 53 | if self._system in {'Windows', 'Linux'}: 54 | return self.name.split()[-1] 55 | return '' 56 | 57 | @cached_property 58 | def label(self) -> str: 59 | """Human-readable name of the port. 60 | 61 | Platform dependant: 62 | - Microsoft Windows (MME): requires removing the ID and preceding space from the string end 63 | - Linux (ALSA): requires removing the interface name and semicolon delimiter from the beginning of the string 64 | and the ID and preceding space from the string end 65 | - Mac OS X (Core MIDI): no processing since they don't seem to use any ID or strange formatting 66 | 67 | 68 | :return: The name of the port. 69 | """ 70 | if self._system == 'Windows': 71 | return self.name[0:-len(self.num) - 1] 72 | if self._system == 'Linux': 73 | return self.name[self.name.index(':') + 1:-len(self.num) - 1] 74 | return self.name 75 | 76 | def close(self) -> None: 77 | """Closes the port. 78 | 79 | """ 80 | self.port.close() 81 | 82 | 83 | class MidiOutPort(MidiPort): 84 | """Manages output ports. 85 | 86 | A thin wrapper around Mido. 87 | 88 | """ 89 | port: mido.ports.BaseOutput 90 | 91 | def open(self) -> None: 92 | """Opens the port. 93 | 94 | """ 95 | self.port = mido.open_output(self.name) 96 | 97 | 98 | class MidiInPort(MidiPort): 99 | """Manages output ports. 100 | 101 | A thin wrapper around Mido. 102 | 103 | """ 104 | port: mido.ports.BaseInput 105 | dest: None | MidiOutPort | str = None # We can only open the port once. Therefore, only one destination exists. 106 | 107 | @property 108 | def mode(self) -> str: 109 | """Gives the mode in which the port operates. 110 | 111 | :return: Either 'callback' or 'polling'. 112 | 113 | """ 114 | if self.port.callback is not None: 115 | mode = 'callback' 116 | else: 117 | mode = 'polling' 118 | return mode 119 | 120 | def open(self, dest: MidiOutPort | str) -> None: 121 | """Opens the port to the given destination. 122 | 123 | :param dest: Destination port or module. 124 | 125 | """ 126 | self.dest = dest 127 | self.port = mido.open_input(self.name) 128 | 129 | def close(self) -> None: 130 | """Closes the port. 131 | 132 | """ 133 | self.port.callback = None 134 | self.dest = None 135 | super().close() 136 | 137 | def callback(self) -> None: 138 | """Sets the port in callback mode. 139 | 140 | This is the recommended mode for the best performance. 141 | 142 | """ 143 | with midi_in_lock: 144 | self.port.callback = self.receive_callback 145 | 146 | def polling(self) -> None: 147 | """Sets the port in polling mode. 148 | 149 | Not recommended except when the need to debug arises. 150 | 151 | """ 152 | self.port.callback = None 153 | 154 | def receive_callback(self, midi_message: mido.Message) -> None: 155 | """Processes the messages received in callback mode. 156 | 157 | :param midi_message: The received MIDI message. 158 | 159 | """ 160 | # Get the system timestamp ASAP 161 | timestamp = Timestamp() 162 | 163 | logger = Logger() 164 | logger.log_debug(f"Callback data: {midi_message} from {self.label} to {self.dest}") 165 | 166 | with midi_in_lock: 167 | midi_in_queue.put((timestamp, self.label, self.dest, midi_message)) 168 | -------------------------------------------------------------------------------- /src/midiexplorer/midi/timestamp.py: -------------------------------------------------------------------------------- 1 | # 2 | # SPDX-FileCopyrightText: 2022 Raphaël Doursenaud 3 | # 4 | # SPDX-License-Identifier: GPL-3.0-or-later 5 | 6 | """ 7 | Timestamp singleton. 8 | """ 9 | import time 10 | 11 | 12 | class Timestamp: 13 | """Timestamp singleton. 14 | 15 | Allows sharing the latest timestamp globally. 16 | 17 | """ 18 | __instance = None 19 | START_TIME = time.perf_counter() # Initialize ASAP (seconds) 20 | value = 0 # Current timestamp (seconds) 21 | delta = 0 # Delta to previous timestamp (seconds) 22 | 23 | def __new__(cls) -> object: 24 | """Instantiates a new timestamp or retrieves the existing one. 25 | 26 | :return: A timestamp instance. 27 | 28 | """ 29 | if Timestamp.__instance is None: 30 | Timestamp.__instance = super(Timestamp, cls).__new__(cls) 31 | return cls.__instance 32 | 33 | def __init__(self): 34 | now = time.perf_counter() - self.START_TIME 35 | self.delta = now - self.value 36 | self.value = now 37 | --------------------------------------------------------------------------------