Files
gti_radiostudio/midi_engine.py
T

381 lines
14 KiB
Python
Raw Normal View History

2026-04-29 16:21:44 +10:00
# midi_engine.py
"""
MIDI Engine for GTI Radio Studio.
Supports MIDI learn and configurable control mapping.
2026-04-29 16:21:44 +10:00
"""
import json
2026-04-29 16:21:44 +10:00
import logging
import os
2026-04-29 16:21:44 +10:00
import threading
from typing import Optional
import rtmidi
from PySide6.QtCore import QObject, Signal
logger = logging.getLogger(__name__)
NOTE_ON = 0x90
NOTE_OFF = 0x80
CC = 0xB0
2026-05-13 20:53:15 +10:00
PITCH_BEND = 0xE0
2026-04-29 16:21:44 +10:00
CONFIG_DIR = os.path.expanduser("~/.gti_radiostudio")
CONFIG_FILE = os.path.join(CONFIG_DIR, "midi_map.json")
2026-04-29 16:21:44 +10:00
JOG_SCRUB_SECONDS = 5.0
JOG_DEAD_ZONE = 5
2026-04-29 16:21:44 +10:00
def _decode_relative_jog(value: int) -> int:
if 1 <= value <= 63:
2026-04-30 00:30:23 +10:00
return value if value >= JOG_DEAD_ZONE else 0
2026-04-29 16:21:44 +10:00
elif 65 <= value <= 127:
2026-04-30 00:30:23 +10:00
backward = 128 - value
return -backward if backward >= JOG_DEAD_ZONE else 0
2026-04-29 16:21:44 +10:00
return 0
class MidiMapping:
def __init__(self):
self.bindings = {}
def add(self, action: str, target_id, midi_type: int, midi_channel: int,
2026-05-13 20:53:15 +10:00
midi_note: int, inverse: bool = False, is_relative: bool = False):
key = (midi_type, midi_channel, midi_note)
self.bindings[key] = {
'action': action,
'target_id': target_id,
'inverse': inverse,
2026-05-13 20:53:15 +10:00
'is_relative': is_relative,
}
def lookup(self, midi_type: int, midi_channel: int,
midi_note: int) -> Optional[dict]:
return self.bindings.get((midi_type, midi_channel, midi_note))
def save(self, filepath: str = CONFIG_FILE):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
data = []
for key, val in self.bindings.items():
data.append({
'midi_type': key[0],
'midi_channel': key[1],
'midi_note': key[2],
'action': val['action'],
'target_id': val['target_id'],
'inverse': val['inverse'],
2026-05-13 20:53:15 +10:00
'is_relative': val.get('is_relative', False),
})
with open(filepath, 'w') as f:
json.dump(data, f, indent=2)
def load(self, filepath: str = CONFIG_FILE):
if not os.path.exists(filepath):
return
with open(filepath) as f:
data = json.load(f)
for entry in data:
key = (entry['midi_type'], entry['midi_channel'], entry['midi_note'])
self.bindings[key] = {
'action': entry['action'],
'target_id': entry['target_id'],
'inverse': entry.get('inverse', False),
2026-05-13 20:53:15 +10:00
'is_relative': entry.get('is_relative', False),
}
2026-04-29 16:21:44 +10:00
class MidiEngine(QObject):
deck_play_pause = Signal(str)
deck_cue = Signal(str)
deck_jog = Signal(str, float)
cart_trigger = Signal(int)
mixer_volume = Signal(int, float)
mixer_mute = Signal(int, bool)
mixer_solo = Signal(int, bool)
mixer_pfl = Signal(int, bool)
mixer_mic_on = Signal(int, bool)
mixer_gain = Signal(int, float)
master_control = Signal(str, float)
master_volume = Signal(float)
2026-04-29 16:21:44 +10:00
controller_connected = Signal(bool, str)
mapping_learned = Signal(str, object, int, int, int, bool)
2026-05-13 20:53:15 +10:00
raw_midi_event = Signal(str, int, int, int)
2026-04-29 16:21:44 +10:00
def __init__(self, parent=None):
super().__init__(parent)
self._midi_in: Optional[rtmidi.MidiIn] = None
self._port_name: str = ""
self._running = False
self.mapping = MidiMapping()
self.mapping.load()
self._learn_target = None
self._learn_mode = False
self._last_cc_values = {}
2026-05-13 20:53:15 +10:00
self._channel_volumes = {}
self._master_knob_values = {}
@property
def learn_mode(self) -> bool:
return self._learn_mode
def set_learn_target(self, target: Optional[tuple]):
self._learn_target = target
def start_learn(self, action: str, target_id):
self._learn_mode = True
self._learn_target = (action, target_id)
logger.info(f"MIDI learn: waiting for {action} ({target_id})...")
def stop_learn(self):
self._learn_mode = False
self._learn_target = None
2026-04-29 16:21:44 +10:00
def start(self, port_index: Optional[int] = None) -> bool:
try:
self._midi_in = rtmidi.MidiIn()
self._midi_in.ignore_types(sysex=True, timing=True, active_sense=True)
available_ports = self._midi_in.get_ports()
logger.info(f"Available MIDI ports: {available_ports}")
if not available_ports:
logger.error("No MIDI ports found.")
self.controller_connected.emit(False, "No MIDI ports found")
return False
if port_index is None:
port_index = self._find_numark_port(available_ports)
self._port_name = available_ports[port_index]
self._midi_in.open_port(port_index)
self._midi_in.set_callback(self._midi_callback)
self._running = True
logger.info(f"MIDI connected: {self._port_name}")
self.controller_connected.emit(True, self._port_name)
return True
except Exception as e:
logger.exception(f"Failed to open MIDI port: {e}")
self.controller_connected.emit(False, str(e))
return False
def stop(self):
self._running = False
if self._midi_in:
try:
self._midi_in.cancel_callback()
self._midi_in.close_port()
except Exception as e:
logger.warning(f"Error closing MIDI port: {e}")
finally:
del self._midi_in
self._midi_in = None
logger.info("MIDI engine stopped.")
def list_ports(self) -> list[str]:
try:
tmp = rtmidi.MidiIn()
ports = tmp.get_ports()
del tmp
return ports
except Exception:
return []
def _find_numark_port(self, ports: list[str]) -> int:
for i, name in enumerate(ports):
if "numark" in name.lower():
logger.info(f"Auto-detected Numark on port {i}: {name}")
return i
logger.warning("No Numark port found, defaulting to port 0.")
return 0
def _midi_callback(self, event, data=None):
if not self._running:
return
message, _ = event
if len(message) < 2:
return
status = message[0]
data_byte = message[1]
value = message[2] if len(message) > 2 else 0
status_type = status & 0xF0
channel = status & 0x0F
2026-05-13 20:53:15 +10:00
type_names = {0x90: "note_on", 0x80: "note_off", 0xB0: "cc", 0xE0: "pitch", 0xD0: "aftertouch"}
type_name = type_names.get(status_type, f"0x{status_type:02X}")
self.raw_midi_event.emit(type_name, channel, data_byte, value)
if self._learn_mode and self._learn_target:
action, target_id = self._learn_target
mapping_type = status_type
2026-05-13 20:53:15 +10:00
is_rel = (status_type == 0xB0 and (1 <= value <= 63 or 65 <= value <= 127))
note = 0 if status_type == PITCH_BEND else data_byte
self.mapping.add(action, target_id, mapping_type, channel, note, is_relative=is_rel)
self.mapping.save()
2026-05-13 20:53:15 +10:00
self.mapping_learned.emit(action, target_id, mapping_type, channel, note, value > 0)
self.stop_learn()
return
2026-04-29 16:21:44 +10:00
if status_type == NOTE_ON and value > 0:
self._handle_note_on(data_byte)
self._handle_learned_event(status_type, channel, data_byte, value)
elif status_type == NOTE_OFF or (status_type == NOTE_ON and value == 0):
pass
2026-04-29 16:21:44 +10:00
elif status_type == CC:
self._handle_cc(data_byte, value)
self._handle_learned_event(status_type, channel, data_byte, value)
2026-05-13 20:53:15 +10:00
elif status_type == PITCH_BEND:
lsb = data_byte
msb = message[2] if len(message) > 2 else 0
pb_value = (msb << 7) | lsb
self._handle_learned_event(status_type, channel, 0, pb_value)
def _handle_learned_event(self, midi_type: int, channel: int,
note: int, value: int):
binding = self.mapping.lookup(midi_type, channel, note)
if binding is None:
return
action = binding['action']
target_id = binding['target_id']
inv = binding.get('inverse', False)
2026-05-13 20:53:15 +10:00
is_rel = binding.get('is_relative', False)
if action == 'deck_play_pause' and isinstance(target_id, str):
self.deck_play_pause.emit(target_id)
elif action == 'deck_cue' and isinstance(target_id, str):
self.deck_cue.emit(target_id)
elif action == 'cart_trigger':
self.cart_trigger.emit(target_id)
elif action == 'mixer_volume':
2026-05-13 20:53:15 +10:00
if is_rel and 1 <= value <= 63:
prev = self._channel_volumes.get(target_id, 1.0)
new_vol = min(1.0, prev + value * 0.02)
self._channel_volumes[target_id] = new_vol
self.mixer_volume.emit(target_id, new_vol)
elif is_rel and 65 <= value <= 127:
prev = self._channel_volumes.get(target_id, 1.0)
steps = value & 0x3F
new_vol = max(0.0, prev - steps * 0.02)
self._channel_volumes[target_id] = new_vol
self.mixer_volume.emit(target_id, new_vol)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
self._channel_volumes[target_id] = scaled
self.mixer_volume.emit(target_id, scaled)
elif action == 'mixer_mute':
muted = (value < 64) if inv else (value > 63)
self.mixer_mute.emit(target_id, muted)
elif action == 'mixer_solo':
on = (value > 63) if not inv else (value < 64)
self.mixer_solo.emit(target_id, on)
elif action == 'mixer_pfl':
on = (value > 63) if not inv else (value < 64)
self.mixer_pfl.emit(target_id, on)
elif action == 'mixer_mic_on':
on = (value > 63) if not inv else (value < 64)
self.mixer_mic_on.emit(target_id, on)
elif action == 'mixer_gain':
if is_rel and 1 <= value <= 63:
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
new_val = min(2.0, prev + value * 0.02)
self._channel_volumes[f"gain_{target_id}"] = new_val
self.mixer_gain.emit(target_id, new_val)
elif is_rel and 65 <= value <= 127:
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
steps = value & 0x3F
new_val = max(0.0, prev - steps * 0.02)
self._channel_volumes[f"gain_{target_id}"] = new_val
self.mixer_gain.emit(target_id, new_val)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
scaled = scaled * 2.0 # map to 0-2 range
self.mixer_gain.emit(target_id, scaled)
elif action == 'mixer_gain':
if is_rel and 1 <= value <= 63:
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
new_val = min(2.0, prev + value * 0.02)
self._channel_volumes[f"gain_{target_id}"] = new_val
self.mixer_gain.emit(target_id, new_val)
elif is_rel and 65 <= value <= 127:
prev = self._channel_volumes.get(f"gain_{target_id}", 1.0)
steps = value & 0x3F
new_val = max(0.0, prev - steps * 0.02)
self._channel_volumes[f"gain_{target_id}"] = new_val
self.mixer_gain.emit(target_id, new_val)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
scaled = scaled * 2.0 # map to 0-2 range
self.mixer_gain.emit(target_id, scaled)
elif action == 'master_control':
2026-05-13 20:53:15 +10:00
knob = str(target_id)
if is_rel and 1 <= value <= 63:
prev = self._master_knob_values.get(knob, 0.5)
new_val = min(1.0, prev + value * 0.02)
self._master_knob_values[knob] = new_val
self.master_control.emit(knob, new_val)
elif is_rel and 65 <= value <= 127:
prev = self._master_knob_values.get(knob, 0.5)
steps = value & 0x3F
new_val = max(0.0, prev - steps * 0.02)
self._master_knob_values[knob] = new_val
self.master_control.emit(knob, new_val)
else:
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
self._master_knob_values[knob] = scaled
self.master_control.emit(knob, scaled)
elif action == 'master_volume':
if midi_type == PITCH_BEND:
scaled = (16383 - value if inv else value) / 16383.0
else:
scaled = (127 - value if inv else value) / 127.0
self.master_volume.emit(scaled)
2026-04-29 16:21:44 +10:00
def _handle_note_on(self, note: int):
# Hardcoded Numark DJ2Go mapping (legacy)
2026-07-17 20:44:15 +10:00
NUMARK_CART = {0x44: 0}
if note == 0x3B:
2026-04-29 16:21:44 +10:00
self.deck_play_pause.emit('deck1')
elif note == 0x33:
2026-04-29 16:21:44 +10:00
self.deck_cue.emit('deck1')
elif note == 0x42:
2026-04-29 16:21:44 +10:00
self.deck_play_pause.emit('deck2')
elif note == 0x3C:
2026-04-29 16:21:44 +10:00
self.deck_cue.emit('deck2')
elif note in NUMARK_CART:
self.cart_trigger.emit(NUMARK_CART[note])
2026-04-29 16:21:44 +10:00
def _handle_cc(self, cc_num: int, value: int):
2026-04-30 00:30:23 +10:00
movement = _decode_relative_jog(value)
if movement == 0:
2026-04-29 16:21:44 +10:00
return
2026-04-30 00:30:23 +10:00
delta = (movement / 10.0) * JOG_SCRUB_SECONDS
2026-04-29 16:21:44 +10:00
if cc_num == 0x19:
2026-04-29 16:21:44 +10:00
self.deck_jog.emit('deck1', delta)
elif cc_num == 0x18:
self.deck_jog.emit('deck2', delta)