Initial commit

This commit is contained in:
2026-01-14 19:18:40 +02:00
commit b19f454bf9
9 changed files with 761 additions and 0 deletions

0
src/__init__.py Normal file
View File

247
src/midi_controller.py Normal file
View File

@@ -0,0 +1,247 @@
from array import array
from dataclasses import dataclass
from typing import Optional, List
MIDI_MSG_TYPES = {
0x80: "NOTE_OFF",
0x90: "NOTE_ON",
0xa0: "KEY_PRESSURE",
0xb0: "CTRL_CHANGE",
0xc0: "PROG_CHANGE",
0xd0: "CHAN_PRESSURE",
0xe0: "PITCH_BEND",
0xf0: "SYSTEM",
}
class MidiMsg:
"""
Represents a single MIDI message
"""
EVENT_TYPE_NOTE_OFF = 0x80
EVENT_TYPE_NOTE_ON = 0x90
EVENT_TYPE_KEY_PRESSURE = 0xa0
EVENT_TYPE_CTRL_CHANGE = 0xb0
EVENT_TYPE_PROG_CHANGE = 0xc0
EVENT_TYPE_CHAN_PRESSURE = 0xd0
EVENT_TYPE_PITCH_BEND = 0xe0
EVENT_TYPE_SYSTEM = 0xf0
@classmethod
def create(cls, status_byte: int, data0: int, data1: int = 0) -> 'MidiMsg':
return cls(array("B", (status_byte, data0, data1)))
@classmethod
def create_from_bytes(cls, byte_array: array) -> 'MidiMsg':
return cls(byte_array)
def __init__(self, b: array):
self.__bytes = b
def __str__(self) -> str:
return f'{self.msg_type_str}(chan={self.channel}, b0={self.data0}, b1={self.data1})'
@property
def msg_type(self) -> int:
return self.__bytes[0] & 0xf0
@property
def msg_type_str(self) -> str:
return MIDI_MSG_TYPES.get(self.msg_type, "<ERROR>")
@property
def channel(self) -> int:
return (self.__bytes[0] & 0x0f) + 1
@property
def data0(self) -> int:
return self.__bytes[1]
@property
def data1(self) -> int:
return self.__bytes[2]
def as_tuple(self) -> (int, int, int):
return tuple(self.__bytes)
class BasicControl:
MIN_STATE = 0x00
MAX_STATE = 0x7f
DEFAULT_STATE = MIN_STATE
def __init__(self, name: str, midi_channel: int, control_id: int, state: int = DEFAULT_STATE):
"""
:param name: name of this control
:param midi_channel: MIDI channel this control operates on
:param control_id: MIDI control (button/knob/led) id (first MIDI data byte)
:param state: initial state of this control
"""
assert 1 <= midi_channel <= 16, f'invalid MIDI channel {midi_channel} (expected 1..16)'
assert 0 <= control_id <= 127, f'invalid control id {control_id} (expected 0..127)'
assert 0 <= state <= 127, f'invalid state {state} (expected 0..127)'
self.name: str = name
self._channel: int = midi_channel
self._id: int = control_id
self._state: int = state
def __str__(self) -> str:
"""
:return: name of this control
"""
return self.name
###############################################################################################
# PROPERTIES ##################################################################################
###############################################################################################
@property
def id(self) -> int:
"""
:return: control id
"""
return self._id
@property
def channel(self) -> int:
"""
:return: MIDI channel this device operates on
"""
return self.channel
@property
def state(self) -> int:
"""
:return: current state of this control
"""
return self._state
###############################################################################################
# MUTATION ####################################################################################
###############################################################################################
def change_state(self, state: int):
"""Change/set current state of this control
:param state: new state of this control
"""
if not (self.MIN_STATE <= state <= self.MAX_STATE):
raise ValueError(f'invalid state: {state} (expected {self.MIN_STATE}..{self.MAX_STATE})')
self._state = state
# TODO: notify state changed
class Led(BasicControl):
LIGHT_OFF = 0x00
LIGHT_ON = 0x7f
def __init__(self, name: str, chan: int, ctrl_id: int, states: List[int] = (LIGHT_OFF, LIGHT_ON)):
"""
:param name: name of this LED control
:param chan: MIDI channel used for this LED
:param ctrl_id: MIDI control ID
:param states: all available states, from the dimmest to the brightest
"""
assert states, 'states cannot be empty'
super().__init__(name, chan, ctrl_id, state=states[0])
class MultiLed(Led):
LIGHT_OFF = 0x00
LIGHT_DIM = 0x01
LIGHT_ON = 0x02
def __init__(self, name: str, chan: int, ctrl_id: int, states: List[int] = (LIGHT_OFF, LIGHT_DIM, LIGHT_ON)):
"""
:param name: name of this LED control
:param chan: MIDI channel used for this LED
:param ctrl_id: MIDI control ID
:param states: all available states, from the dimmest to the brightest
"""
assert states, 'states cannot be empty'
super().__init__(name, chan, ctrl_id, states)
class Button(BasicControl):
STATE_RELEASED = BasicControl.MIN_STATE
STATE_PRESSED = BasicControl.MAX_STATE
EVENT_TYPE = MidiMsg.EVENT_TYPE_NOTE_ON
def __init__(self, name: str, chan: int, ctrl_id: int, led_class: Optional[type] = None):
"""
:param name: name of this button
:param chan: MIDI channel used for this button
:param ctrl_id: button ID (i.e., MIDI data byte 1)
:param led_class: (optional) LED control associated with this button
"""
super().__init__(name, chan, ctrl_id, state=self.STATE_RELEASED)
self._led: Optional[Led] = led_class and led_class(f'LED: {name}', chan, ctrl_id)
###############################################################################################
# PROPERTIES ##################################################################################
###############################################################################################
@property
def led(self) -> Optional[Led]:
return self._led
@property
def has_led(self) -> bool:
"""
:return: True if this button has an LED
"""
return self._led is not None
@property
def is_pressed(self):
"""
:return: True if this button is currently pressed
"""
return self.state == self.STATE_PRESSED
class ModeButton(BasicControl):
EVENT_TYPE = MidiMsg.EVENT_TYPE_NOTE_ON
def __init__(self, name: str, chan: int, ctrl_id: int, value: int):
"""
:param name: name of this button
:param chan: MIDI channel used for this button
:param ctrl_id: button ID (i.e., MIDI data byte 1)
:param value: button mode (i.e., MIDI data byte 2)
"""
super().__init__(name, chan, ctrl_id)
self.value = value
class Knob(BasicControl):
STATE_MAX = 0x7f
STATE_MIN = 0x00
EVENT_TYPE = MidiMsg.EVENT_TYPE_CTRL_CHANGE
def __init__(self, name: str, chan: int, ctrl_id: int, ctrl_id2: int = None, min_: int = STATE_MIN, max_: int = STATE_MAX, mid_: Optional[int] = None):
"""
:param name: name of the knob
:param chan: MIDI channel
:param ctrl_id: control id
:param ctrl_id2: secondary control id for precision marks (0, 64, 112)
:param min_: minimal value of the knob (usually 0)
:param max_: maximal value of the knob (usually 127, or 0x7f)
:param mid_: midpoint val of the knob, or None if no specific midpoint
"""
super().__init__(name, chan, ctrl_id, state=min_)
self.precision_ctrl_id = ctrl_id2 # TODO: actually do something with this control
self.min = min_
self.max = max_
self.mid = mid_
@property
def has_midpoint(self) -> bool:
"""
:return: True if this knob has a concrete midpoint; False if midpoint is somewhere between min and max
"""
return self.mid is not None