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

"""
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()