You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
14 KiB
379 lines
14 KiB
"""
|
|
Maschine Interface Module
|
|
|
|
Native Instruments Maschine integration for hardware control of the arpeggiator.
|
|
Provides MIDI mapping for pads, encoders, and buttons.
|
|
"""
|
|
|
|
import mido
|
|
import threading
|
|
import time
|
|
from typing import Dict, List, Optional, Callable, Any
|
|
from PyQt5.QtCore import QObject, pyqtSignal
|
|
|
|
class MaschineInterface(QObject):
|
|
"""
|
|
Interface for Native Instruments Maschine controllers.
|
|
Handles MIDI input/output for pads, encoders, buttons, and LED feedback.
|
|
"""
|
|
|
|
# Signals
|
|
pad_pressed = pyqtSignal(int, int) # pad_number, velocity
|
|
pad_released = pyqtSignal(int) # pad_number
|
|
encoder_changed = pyqtSignal(int, int) # encoder_number, delta
|
|
button_pressed = pyqtSignal(int) # button_number
|
|
button_released = pyqtSignal(int) # button_number
|
|
|
|
# Maschine MIDI mapping (for Maschine MK3)
|
|
MASCHINE_PADS = {
|
|
# Pad numbers to MIDI notes
|
|
1: 36, 2: 37, 3: 38, 4: 39,
|
|
5: 40, 6: 41, 7: 42, 8: 43,
|
|
9: 44, 10: 45, 11: 46, 12: 47,
|
|
13: 48, 14: 49, 15: 50, 16: 51
|
|
}
|
|
|
|
MASCHINE_ENCODERS = {
|
|
# Encoder numbers to MIDI CC
|
|
1: 1, # Encoder 1 (Tempo/Master)
|
|
2: 2, # Encoder 2 (Swing)
|
|
3: 3, # Encoder 3 (Pattern)
|
|
4: 4, # Encoder 4 (Scale)
|
|
5: 5, # Encoder 5 (Volume Pattern)
|
|
6: 6, # Encoder 6 (Pattern Speed)
|
|
7: 7, # Encoder 7 (Gate)
|
|
8: 8 # Encoder 8 (Root Note)
|
|
}
|
|
|
|
MASCHINE_BUTTONS = {
|
|
# Button numbers to MIDI notes/CC
|
|
"scene1": 52, "scene2": 53, "scene3": 54, "scene4": 55,
|
|
"group": 56, "browse": 57, "sampling": 58, "all": 59,
|
|
"auto": 60, "lock": 61, "note_repeat": 62, "restart": 63
|
|
}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
# MIDI connections
|
|
self.midi_input = None
|
|
self.midi_output = None
|
|
self.input_device_name = ""
|
|
self.output_device_name = ""
|
|
|
|
# State tracking
|
|
self.pad_states = {} # pad -> velocity
|
|
self.encoder_values = {} # encoder -> current_value
|
|
self.button_states = {} # button -> pressed
|
|
|
|
# Control mappings (what each control does)
|
|
self.pad_mappings = {} # pad -> function
|
|
self.encoder_mappings = {} # encoder -> function
|
|
self.button_mappings = {} # button -> function
|
|
|
|
# LED states for feedback
|
|
self.pad_leds = {i: 0 for i in range(1, 17)} # pad -> brightness (0-127)
|
|
|
|
# Thread for MIDI input processing
|
|
self.input_thread = None
|
|
self.running = False
|
|
|
|
# Setup default mappings
|
|
self.setup_default_mappings()
|
|
|
|
def setup_default_mappings(self):
|
|
"""Setup default control mappings"""
|
|
|
|
# Pad mappings - trigger notes for arpeggiator
|
|
for i in range(1, 17):
|
|
self.pad_mappings[i] = {
|
|
'type': 'note_trigger',
|
|
'note': 48 + i, # C3 and up
|
|
'function': None
|
|
}
|
|
|
|
# Encoder mappings
|
|
self.encoder_mappings = {
|
|
1: {'type': 'tempo', 'min': 40, 'max': 200, 'step': 1},
|
|
2: {'type': 'swing', 'min': -100, 'max': 100, 'step': 5},
|
|
3: {'type': 'pattern_select', 'values': ['up', 'down', 'up_down', 'random']},
|
|
4: {'type': 'scale_select', 'values': ['major', 'minor', 'dorian', 'pentatonic_major']},
|
|
5: {'type': 'volume_pattern', 'values': ['static', 'swell', 'breathing', 'wave']},
|
|
6: {'type': 'pattern_speed', 'min': 0.1, 'max': 5.0, 'step': 0.1},
|
|
7: {'type': 'gate', 'min': 0.1, 'max': 2.0, 'step': 0.05},
|
|
8: {'type': 'root_note', 'min': 0, 'max': 127, 'step': 1}
|
|
}
|
|
|
|
# Button mappings
|
|
self.button_mappings = {
|
|
'scene1': {'type': 'preset', 'preset_number': 1},
|
|
'scene2': {'type': 'preset', 'preset_number': 2},
|
|
'scene3': {'type': 'preset', 'preset_number': 3},
|
|
'scene4': {'type': 'preset', 'preset_number': 4},
|
|
'group': {'type': 'output_mode_toggle'},
|
|
'browse': {'type': 'routing_pattern_cycle'},
|
|
'sampling': {'type': 'volume_pattern_cycle'},
|
|
'all': {'type': 'panic'},
|
|
'note_repeat': {'type': 'hold_toggle'},
|
|
'restart': {'type': 'restart_pattern'}
|
|
}
|
|
|
|
def find_maschine_devices(self) -> tuple:
|
|
"""Find Maschine MIDI devices"""
|
|
inputs = mido.get_input_names()
|
|
outputs = mido.get_output_names()
|
|
|
|
maschine_inputs = [name for name in inputs if 'maschine' in name.lower()]
|
|
maschine_outputs = [name for name in outputs if 'maschine' in name.lower()]
|
|
|
|
return maschine_inputs, maschine_outputs
|
|
|
|
def connect(self, input_name: str = None, output_name: str = None) -> bool:
|
|
"""Connect to Maschine devices"""
|
|
try:
|
|
# Auto-detect if not specified
|
|
if not input_name or not output_name:
|
|
inputs, outputs = self.find_maschine_devices()
|
|
if not inputs or not outputs:
|
|
return False
|
|
input_name = input_name or inputs[0]
|
|
output_name = output_name or outputs[0]
|
|
|
|
# Close existing connections
|
|
self.disconnect()
|
|
|
|
# Open MIDI connections
|
|
self.midi_input = mido.open_input(input_name, callback=self._process_midi_message)
|
|
self.midi_output = mido.open_output(output_name)
|
|
|
|
self.input_device_name = input_name
|
|
self.output_device_name = output_name
|
|
self.running = True
|
|
|
|
# Initialize Maschine state
|
|
self.initialize_maschine()
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error connecting to Maschine: {e}")
|
|
return False
|
|
|
|
def disconnect(self):
|
|
"""Disconnect from Maschine devices"""
|
|
self.running = False
|
|
|
|
if self.midi_input:
|
|
try:
|
|
self.midi_input.close()
|
|
except:
|
|
pass
|
|
self.midi_input = None
|
|
|
|
if self.midi_output:
|
|
try:
|
|
# Turn off all LEDs
|
|
self.all_leds_off()
|
|
self.midi_output.close()
|
|
except:
|
|
pass
|
|
self.midi_output = None
|
|
|
|
def initialize_maschine(self):
|
|
"""Initialize Maschine controller state"""
|
|
if not self.midi_output:
|
|
return
|
|
|
|
# Turn off all pad LEDs
|
|
self.all_leds_off()
|
|
|
|
# Set initial pad colors/brightness based on current state
|
|
self.update_pad_leds()
|
|
|
|
def _process_midi_message(self, message):
|
|
"""Process incoming MIDI messages from Maschine"""
|
|
if not self.running:
|
|
return
|
|
|
|
try:
|
|
if message.type == 'note_on':
|
|
self._handle_pad_press(message.note, message.velocity)
|
|
elif message.type == 'note_off':
|
|
self._handle_pad_release(message.note)
|
|
elif message.type == 'control_change':
|
|
self._handle_encoder_change(message.control, message.value)
|
|
|
|
except Exception as e:
|
|
print(f"Error processing MIDI message: {e}")
|
|
|
|
def _handle_pad_press(self, midi_note: int, velocity: int):
|
|
"""Handle pad press"""
|
|
# Find pad number from MIDI note
|
|
pad_number = None
|
|
for pad, note in self.MASCHINE_PADS.items():
|
|
if note == midi_note:
|
|
pad_number = pad
|
|
break
|
|
|
|
if pad_number:
|
|
self.pad_states[pad_number] = velocity
|
|
self.pad_pressed.emit(pad_number, velocity)
|
|
|
|
# Update LED
|
|
self.set_pad_led(pad_number, velocity)
|
|
|
|
def _handle_pad_release(self, midi_note: int):
|
|
"""Handle pad release"""
|
|
# Find pad number from MIDI note
|
|
pad_number = None
|
|
for pad, note in self.MASCHINE_PADS.items():
|
|
if note == midi_note:
|
|
pad_number = pad
|
|
break
|
|
|
|
if pad_number:
|
|
self.pad_states[pad_number] = 0
|
|
self.pad_released.emit(pad_number)
|
|
|
|
# Update LED
|
|
self.set_pad_led(pad_number, 0)
|
|
|
|
def _handle_encoder_change(self, cc_number: int, value: int):
|
|
"""Handle encoder change"""
|
|
# Find encoder number from CC
|
|
encoder_number = None
|
|
for enc, cc in self.MASCHINE_ENCODERS.items():
|
|
if cc == cc_number:
|
|
encoder_number = enc
|
|
break
|
|
|
|
if encoder_number:
|
|
# Calculate delta (encoders send relative values)
|
|
old_value = self.encoder_values.get(encoder_number, 64)
|
|
delta = value - old_value
|
|
|
|
# Handle encoder wrap-around
|
|
if delta > 64:
|
|
delta = delta - 128
|
|
elif delta < -64:
|
|
delta = delta + 128
|
|
|
|
self.encoder_values[encoder_number] = value
|
|
self.encoder_changed.emit(encoder_number, delta)
|
|
|
|
def set_pad_led(self, pad_number: int, brightness: int, color: str = "white"):
|
|
"""Set pad LED brightness and color"""
|
|
if not self.midi_output or pad_number not in self.MASCHINE_PADS:
|
|
return
|
|
|
|
try:
|
|
# Maschine uses velocity for LED brightness in note messages
|
|
# Different channels can represent different colors
|
|
channel = 0 # Default channel for white
|
|
if color == "red":
|
|
channel = 1
|
|
elif color == "green":
|
|
channel = 2
|
|
elif color == "blue":
|
|
channel = 3
|
|
elif color == "yellow":
|
|
channel = 4
|
|
|
|
midi_note = self.MASCHINE_PADS[pad_number]
|
|
msg = mido.Message('note_on', channel=channel, note=midi_note, velocity=brightness)
|
|
self.midi_output.send(msg)
|
|
|
|
self.pad_leds[pad_number] = brightness
|
|
|
|
except Exception as e:
|
|
print(f"Error setting pad LED: {e}")
|
|
|
|
def set_pad_color_brightness(self, pad_number: int, brightness: float, color_hue: float = 0.0):
|
|
"""Set pad LED with color based on hue (0.0-1.0) and brightness (0.0-1.0)"""
|
|
if brightness < 0.1:
|
|
self.set_pad_led(pad_number, 0)
|
|
return
|
|
|
|
# Convert brightness to MIDI velocity
|
|
velocity = int(brightness * 127)
|
|
|
|
# Simple color mapping based on hue
|
|
if 0.0 <= color_hue < 0.2: # Red
|
|
color = "red"
|
|
elif 0.2 <= color_hue < 0.4: # Yellow
|
|
color = "yellow"
|
|
elif 0.4 <= color_hue < 0.6: # Green
|
|
color = "green"
|
|
elif 0.6 <= color_hue < 0.8: # Blue
|
|
color = "blue"
|
|
else: # White
|
|
color = "white"
|
|
|
|
self.set_pad_led(pad_number, velocity, color)
|
|
|
|
def all_leds_off(self):
|
|
"""Turn off all pad LEDs"""
|
|
for pad in range(1, 17):
|
|
self.set_pad_led(pad, 0)
|
|
|
|
def update_pad_leds(self):
|
|
"""Update all pad LEDs based on current state"""
|
|
# This would be called by the main application to update LED states
|
|
# based on current arpeggiator state, active channels, etc.
|
|
pass
|
|
|
|
def flash_pad(self, pad_number: int, color: str = "white", duration_ms: int = 100):
|
|
"""Flash a pad LED briefly"""
|
|
self.set_pad_led(pad_number, 127, color)
|
|
|
|
# Schedule turning off (would need QTimer in real implementation)
|
|
def turn_off():
|
|
time.sleep(duration_ms / 1000.0)
|
|
self.set_pad_led(pad_number, 0)
|
|
|
|
threading.Thread(target=turn_off, daemon=True).start()
|
|
|
|
def set_encoder_mapping(self, encoder_number: int, mapping: Dict[str, Any]):
|
|
"""Set custom mapping for an encoder"""
|
|
if 1 <= encoder_number <= 8:
|
|
self.encoder_mappings[encoder_number] = mapping
|
|
|
|
def set_pad_mapping(self, pad_number: int, mapping: Dict[str, Any]):
|
|
"""Set custom mapping for a pad"""
|
|
if 1 <= pad_number <= 16:
|
|
self.pad_mappings[pad_number] = mapping
|
|
|
|
def get_encoder_mapping(self, encoder_number: int) -> Optional[Dict[str, Any]]:
|
|
"""Get current mapping for an encoder"""
|
|
return self.encoder_mappings.get(encoder_number)
|
|
|
|
def get_pad_mapping(self, pad_number: int) -> Optional[Dict[str, Any]]:
|
|
"""Get current mapping for a pad"""
|
|
return self.pad_mappings.get(pad_number)
|
|
|
|
def send_feedback_pulse(self, pad_number: int):
|
|
"""Send visual feedback for successful action"""
|
|
self.flash_pad(pad_number, "green", 150)
|
|
|
|
def send_error_feedback(self, pad_number: int):
|
|
"""Send visual feedback for error/invalid action"""
|
|
self.flash_pad(pad_number, "red", 300)
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Check if Maschine is connected"""
|
|
return self.midi_input is not None and self.midi_output is not None
|
|
|
|
def get_status(self) -> Dict[str, Any]:
|
|
"""Get current Maschine interface status"""
|
|
return {
|
|
'connected': self.is_connected(),
|
|
'input_device': self.input_device_name,
|
|
'output_device': self.output_device_name,
|
|
'active_pads': len([p for p, v in self.pad_states.items() if v > 0]),
|
|
'pad_states': self.pad_states.copy(),
|
|
'encoder_values': self.encoder_values.copy()
|
|
}
|
|
|
|
def __del__(self):
|
|
"""Cleanup on destruction"""
|
|
self.disconnect()
|