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