├── .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 |
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 |
158 |
--------------------------------------------------------------------------------
/data/assets/sources/MIDI_connector2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
30 |
--------------------------------------------------------------------------------
/data/assets/sources/Steering_Wheel_Black.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 |
--------------------------------------------------------------------------------