featsss: initial commit
This commit is contained in:
commit
d83cf8ce00
7 changed files with 746 additions and 0 deletions
54
README.md
Normal file
54
README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# MIDI-to-Hue Controller
|
||||||
|
|
||||||
|
A modular Python application for controlling Philips Hue lights with MIDI controllers.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Map MIDI notes and control changes to Hue light parameters
|
||||||
|
- Throttled updates to prevent overwhelming the Hue Bridge
|
||||||
|
- Configurable mappings via JSON
|
||||||
|
- LED feedback support for controller visualization
|
||||||
|
- Customizable value transformations
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
This application has been refactored to follow modular design principles:
|
||||||
|
|
||||||
|
- `config.py` - Configuration management module
|
||||||
|
- `hue_controller.py` - Philips Hue bridge and light control
|
||||||
|
- `midi_controller.py` - MIDI device management
|
||||||
|
- `mapper.py` - Mapping logic between MIDI and Hue
|
||||||
|
- `main.py` - Application entry point
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Connect your MIDI device
|
||||||
|
2. Edit `midi_hue_config.json` to configure your mappings
|
||||||
|
3. Run `python main.py`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The default configuration file (`midi_hue_config.json`) contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"midi_device": 1,
|
||||||
|
"update_interval_ms": 50,
|
||||||
|
"bridge_ip": "192.168.178.35",
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"midi_channel": 5,
|
||||||
|
"midi_control": 1,
|
||||||
|
"light_name": "Zimmer Decke",
|
||||||
|
"parameter": "bri",
|
||||||
|
"value_transform": "value * 2"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- mido
|
||||||
|
- phue
|
||||||
10
__init__.py
Normal file
10
__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""
|
||||||
|
MIDI-to-Hue package.
|
||||||
|
Allows controlling Philips Hue lights with MIDI controllers.
|
||||||
|
"""
|
||||||
|
from .config import ConfigManager
|
||||||
|
from .hue_controller import HueController, ThrottledUpdater
|
||||||
|
from .midi_controller import MidiController, DeviceMappingManager
|
||||||
|
from .mapper import MidiToHueMapper
|
||||||
|
|
||||||
|
__version__ = '1.0.0'
|
||||||
81
config.py
Normal file
81
config.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""
|
||||||
|
Configuration module for MIDI-to-Hue application.
|
||||||
|
Handles loading, creating and validating configuration.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
# Default config file path
|
||||||
|
DEFAULT_CONFIG_FILE = 'midi_hue_config.json'
|
||||||
|
|
||||||
|
# Default MIDI-to-Hue mapping if no config file exists
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
"midi_device": 1, # Index of MIDI device to use (from available inputs list)
|
||||||
|
"update_interval_ms": 50, # Throttling interval in milliseconds
|
||||||
|
"bridge_ip": "192.168.178.35", # Default Bridge IP
|
||||||
|
"mappings": [
|
||||||
|
{
|
||||||
|
"midi_channel": 5,
|
||||||
|
"midi_control": 1,
|
||||||
|
"light_name": "Zimmer Decke",
|
||||||
|
"parameter": "bri",
|
||||||
|
"value_transform": "value * 2" # MIDI range 0-127 to Hue brightness 0-254
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"midi_channel": 6,
|
||||||
|
"midi_control": 1,
|
||||||
|
"light_name": "Fernseher links",
|
||||||
|
"parameter": "bri",
|
||||||
|
"value_transform": "value * 2" # MIDI range 0-127 to Hue brightness 0-254
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
"""Manages configuration loading, validation and access."""
|
||||||
|
|
||||||
|
def __init__(self, config_file: str = DEFAULT_CONFIG_FILE):
|
||||||
|
"""Initialize the config manager with a specific config file."""
|
||||||
|
self.config_file = config_file
|
||||||
|
self.config = self._load_or_create_config()
|
||||||
|
|
||||||
|
def _load_or_create_config(self) -> Dict[str, Any]:
|
||||||
|
"""Load config from file or create with defaults if it doesn't exist."""
|
||||||
|
try:
|
||||||
|
if os.path.exists(self.config_file):
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
print(f"Loaded configuration from {self.config_file}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading config file: {e}")
|
||||||
|
|
||||||
|
# If we get here, either the file doesn't exist or there was an error
|
||||||
|
# Create the default config file
|
||||||
|
with open(self.config_file, 'w') as f:
|
||||||
|
json.dump(DEFAULT_CONFIG, f, indent=4)
|
||||||
|
print(f"Created default configuration file at {self.config_file}")
|
||||||
|
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a configuration value by key with a default fallback."""
|
||||||
|
return self.config.get(key, default)
|
||||||
|
|
||||||
|
def get_mappings(self) -> list:
|
||||||
|
"""Get all mappings from the configuration."""
|
||||||
|
return self.config.get("mappings", [])
|
||||||
|
|
||||||
|
def get_bridge_ip(self) -> str:
|
||||||
|
"""Get the Bridge IP address."""
|
||||||
|
return self.config.get("bridge_ip", "192.168.178.35")
|
||||||
|
|
||||||
|
def get_update_interval_sec(self) -> float:
|
||||||
|
"""Get the update interval in seconds."""
|
||||||
|
update_interval_ms = self.config.get("update_interval_ms", 50)
|
||||||
|
return update_interval_ms / 1000 # Convert to seconds
|
||||||
|
|
||||||
|
def get_midi_device_index(self) -> int:
|
||||||
|
"""Get the MIDI device index."""
|
||||||
|
return self.config.get("midi_device", 1)
|
||||||
132
hue_controller.py
Normal file
132
hue_controller.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"""
|
||||||
|
Hue Controller module for MIDI-to-Hue application.
|
||||||
|
Manages connections to the Hue Bridge and light controls.
|
||||||
|
"""
|
||||||
|
from phue import Bridge
|
||||||
|
import time
|
||||||
|
from threading import Timer
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
class ThrottledUpdater:
|
||||||
|
"""
|
||||||
|
Updates Hue lights with rate limiting to prevent overwhelming the bridge.
|
||||||
|
Collects and consolidates multiple rapid updates into fewer bridge calls.
|
||||||
|
"""
|
||||||
|
def __init__(self, bridge, update_interval: float = 0.05):
|
||||||
|
"""
|
||||||
|
Initialize the throttled updater.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bridge: The Hue Bridge object
|
||||||
|
update_interval: Time between updates in seconds
|
||||||
|
"""
|
||||||
|
self.bridge = bridge
|
||||||
|
self.update_interval = update_interval
|
||||||
|
self.last_update_time = 0
|
||||||
|
self.pending_updates = {} # {light_name: {param: value, ...}, ...}
|
||||||
|
self.update_timer = None
|
||||||
|
print(f"ThrottledUpdater initialized with interval: {update_interval * 1000:.0f}ms")
|
||||||
|
|
||||||
|
def schedule_update(self, light_name: str, param: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Schedule an update for a specific light and parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
light_name: Name of the light to update
|
||||||
|
param: Parameter to update (e.g., 'bri', 'hue', 'on')
|
||||||
|
value: New value for the parameter
|
||||||
|
"""
|
||||||
|
# Store the most recent value for this light and parameter
|
||||||
|
if light_name not in self.pending_updates:
|
||||||
|
self.pending_updates[light_name] = {}
|
||||||
|
self.pending_updates[light_name][param] = value
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
time_since_last_update = current_time - self.last_update_time
|
||||||
|
|
||||||
|
# If no timer is active and it's been longer than our interval, update immediately
|
||||||
|
if self.update_timer is None and time_since_last_update >= self.update_interval:
|
||||||
|
self._perform_updates()
|
||||||
|
# Otherwise, ensure we have a timer scheduled
|
||||||
|
elif self.update_timer is None:
|
||||||
|
# Calculate remaining time until next update
|
||||||
|
remaining_time = max(0, self.update_interval - time_since_last_update)
|
||||||
|
self.update_timer = Timer(remaining_time, self._perform_updates)
|
||||||
|
self.update_timer.start()
|
||||||
|
|
||||||
|
def _perform_updates(self) -> None:
|
||||||
|
"""Apply all pending updates to the Hue bridge."""
|
||||||
|
if self.update_timer:
|
||||||
|
self.update_timer.cancel()
|
||||||
|
self.update_timer = None
|
||||||
|
|
||||||
|
# Apply all pending updates
|
||||||
|
for light_name, params in self.pending_updates.items():
|
||||||
|
for param, value in params.items():
|
||||||
|
try:
|
||||||
|
self.bridge.set_light(light_name, param, value)
|
||||||
|
print(f"Updated {light_name} {param} = {value}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating light {light_name}: {e}")
|
||||||
|
|
||||||
|
# Clear pending updates and update timestamp
|
||||||
|
self.pending_updates = {}
|
||||||
|
self.last_update_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class HueController:
|
||||||
|
"""Manages interactions with the Philips Hue system."""
|
||||||
|
|
||||||
|
def __init__(self, bridge_ip: str, update_interval: float = 0.05):
|
||||||
|
"""
|
||||||
|
Initialize the Hue controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bridge_ip: IP address of the Hue Bridge
|
||||||
|
update_interval: Time between updates in seconds
|
||||||
|
"""
|
||||||
|
self.bridge_ip = bridge_ip
|
||||||
|
self.bridge = None
|
||||||
|
self.updater = None
|
||||||
|
self.update_interval = update_interval
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
"""Connect to the Hue Bridge and initialize the updater."""
|
||||||
|
try:
|
||||||
|
self.bridge = Bridge(self.bridge_ip)
|
||||||
|
self.bridge.connect()
|
||||||
|
self.updater = ThrottledUpdater(self.bridge, self.update_interval)
|
||||||
|
print(f"Connected to Hue Bridge at {self.bridge_ip}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to connect to Hue Bridge: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_lights(self) -> None:
|
||||||
|
"""Print available lights for reference."""
|
||||||
|
print("Available Hue lights:")
|
||||||
|
for light in self.bridge.lights:
|
||||||
|
print(f" {light.name} - Current brightness: {light.brightness}")
|
||||||
|
|
||||||
|
def get_light_by_name(self, light_name: str):
|
||||||
|
"""Get a light object by its name."""
|
||||||
|
try:
|
||||||
|
return self.bridge.get_light_objects('name')[light_name]
|
||||||
|
except KeyError:
|
||||||
|
print(f"Light '{light_name}' not found.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update_light(self, light_name: str, parameter: str, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Schedule an update for a light parameter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
light_name: Name of the light to update
|
||||||
|
parameter: Parameter to update (e.g., 'bri', 'hue', 'on')
|
||||||
|
value: New value for the parameter
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
print("Skipping update due to None value")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.updater.schedule_update(light_name, parameter, value)
|
||||||
75
main.py
Normal file
75
main.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""
|
||||||
|
Main module for MIDI-to-Hue application.
|
||||||
|
Ties together the config, MIDI controller, Hue controller, and mapper.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from config import ConfigManager
|
||||||
|
from hue_controller import HueController
|
||||||
|
from midi_controller import MidiController, DeviceMappingManager
|
||||||
|
from mapper import MidiToHueMapper
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main application entry point."""
|
||||||
|
# Load configuration
|
||||||
|
config_manager = ConfigManager()
|
||||||
|
|
||||||
|
# Initialize Hue controller
|
||||||
|
try:
|
||||||
|
hue_controller = HueController(
|
||||||
|
config_manager.get_bridge_ip(),
|
||||||
|
config_manager.get_update_interval_sec()
|
||||||
|
)
|
||||||
|
hue_controller.list_lights()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to initialize Hue controller: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Initialize MIDI controller
|
||||||
|
midi_controller = MidiController(config_manager.get_midi_device_index())
|
||||||
|
midi_controller.list_devices()
|
||||||
|
|
||||||
|
# Setup device mappings
|
||||||
|
device_mappings = {
|
||||||
|
"traktor_kontrol_s2": {
|
||||||
|
"note_on/1/3": 'left_deck_1',
|
||||||
|
"note_on/1/4": 'left_deck_2',
|
||||||
|
"note_on/1/5": 'left_deck_3',
|
||||||
|
"note_on/1/6": 'left_deck_4',
|
||||||
|
"note_on/1/7": 'left_deck_5',
|
||||||
|
"note_on/1/8": 'left_deck_6',
|
||||||
|
"note_on/1/9": 'left_deck_7',
|
||||||
|
"note_on/1/10": 'left_deck_8',
|
||||||
|
"control_change/5/1": 'left_volume_slider',
|
||||||
|
"control_change/6/1": 'right_volume_slider',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
device_mapper = DeviceMappingManager(device_mappings)
|
||||||
|
device_mapper.set_active_device("traktor_kontrol_s2")
|
||||||
|
|
||||||
|
# Bind the device mapper to the MIDI controller
|
||||||
|
midi_controller.get_input_name = device_mapper.get_input_name
|
||||||
|
|
||||||
|
# Create MIDI-to-Hue mapper with configuration
|
||||||
|
mapper = MidiToHueMapper(
|
||||||
|
hue_controller,
|
||||||
|
midi_controller,
|
||||||
|
config_manager.get_mappings()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open MIDI port and process messages
|
||||||
|
if not midi_controller.open():
|
||||||
|
print("Failed to open MIDI port.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
midi_controller.process_messages()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nExiting program...")
|
||||||
|
finally:
|
||||||
|
midi_controller.close()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
163
mapper.py
Normal file
163
mapper.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"""
|
||||||
|
Mapper module for MIDI-to-Hue application.
|
||||||
|
Handles the mapping logic between MIDI messages and Hue light controls.
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import mido
|
||||||
|
|
||||||
|
class MidiToHueMapper:
|
||||||
|
"""Maps MIDI messages to Hue light commands according to configuration."""
|
||||||
|
|
||||||
|
def __init__(self, hue_controller, midi_controller, mappings: list):
|
||||||
|
"""
|
||||||
|
Initialize the MIDI-to-Hue mapper.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hue_controller: HueController instance
|
||||||
|
midi_controller: MidiController instance
|
||||||
|
mappings: List of mapping dictionaries from config
|
||||||
|
"""
|
||||||
|
self.hue_controller = hue_controller
|
||||||
|
self.midi_controller = midi_controller
|
||||||
|
self.mappings = mappings
|
||||||
|
self.last_note_hit_light_name = None
|
||||||
|
|
||||||
|
# Register as a handler for MIDI messages
|
||||||
|
self.midi_controller.register_handler(self.process_midi_message)
|
||||||
|
|
||||||
|
def process_midi_message(self, msg) -> None:
|
||||||
|
"""
|
||||||
|
Process a MIDI message and update lights if it matches any mappings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: MIDI message object from mido
|
||||||
|
"""
|
||||||
|
# Extract MIDI message properties
|
||||||
|
if not hasattr(msg, 'type') or not hasattr(msg, 'channel'):
|
||||||
|
return # Skip messages without required attributes
|
||||||
|
|
||||||
|
# Extract value (could be in different attributes depending on message type)
|
||||||
|
default_value = None
|
||||||
|
if hasattr(msg, 'value'):
|
||||||
|
default_value = msg.value
|
||||||
|
elif hasattr(msg, 'velocity'):
|
||||||
|
default_value = msg.velocity
|
||||||
|
|
||||||
|
# Get the input name for this message if available
|
||||||
|
input_name = None
|
||||||
|
if hasattr(self.midi_controller, 'get_input_name'):
|
||||||
|
input_name = self.midi_controller.get_input_name(msg)
|
||||||
|
|
||||||
|
# Process message against our mappings
|
||||||
|
for mapping in self.mappings:
|
||||||
|
# Match by input_name if available, otherwise fall back to channel/control matching
|
||||||
|
if input_name and 'input_name' in mapping:
|
||||||
|
if input_name != mapping['input_name']:
|
||||||
|
continue
|
||||||
|
elif msg.type == "note_on" and 'midi_channel' in mapping and 'midi_control' in mapping:
|
||||||
|
if msg.channel != mapping['midi_channel'] or (hasattr(msg, 'note') and msg.note != mapping['midi_control']):
|
||||||
|
continue
|
||||||
|
elif msg.type == "control_change" and 'midi_channel' in mapping and 'midi_control' in mapping:
|
||||||
|
if msg.channel != mapping['midi_channel'] or (hasattr(msg, 'control') and msg.control != mapping['midi_control']):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
continue # No match criteria found
|
||||||
|
|
||||||
|
# Determine the light name
|
||||||
|
light_name = self._resolve_light_name(mapping, msg)
|
||||||
|
if light_name is None:
|
||||||
|
print("No light name specified, skipping update.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update last note hit for potential use in value_transform
|
||||||
|
if msg.type == "note_on":
|
||||||
|
self.last_note_hit_light_name = light_name
|
||||||
|
|
||||||
|
# Verify the light exists
|
||||||
|
light = self.hue_controller.get_light_by_name(light_name)
|
||||||
|
if not light:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply value transformation
|
||||||
|
transformed_value = self._transform_value(mapping, msg, light, default_value)
|
||||||
|
if transformed_value is None:
|
||||||
|
print("Skipping update due to None value")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update LED feedback if needed
|
||||||
|
if msg.type == "note_on" and mapping.get('parameter') == 'on':
|
||||||
|
self._send_led_feedback(msg, transformed_value)
|
||||||
|
|
||||||
|
# Schedule the light update
|
||||||
|
self.hue_controller.update_light(
|
||||||
|
light_name,
|
||||||
|
mapping['parameter'],
|
||||||
|
transformed_value
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"MIDI: {msg} → {light_name}.{mapping['parameter']} = {transformed_value}")
|
||||||
|
|
||||||
|
def _resolve_light_name(self, mapping: Dict[str, Any], msg) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Resolve the light name based on mapping configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mapping: Mapping configuration dictionary
|
||||||
|
msg: MIDI message object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resolved light name or None if not resolved
|
||||||
|
"""
|
||||||
|
if "light_name_eval" in mapping:
|
||||||
|
try:
|
||||||
|
return eval(mapping['light_name_eval'], {
|
||||||
|
'msg': msg,
|
||||||
|
'last_note_hit_light_name': self.last_note_hit_light_name,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error evaluating light_name_eval: {e}")
|
||||||
|
return None
|
||||||
|
return mapping.get('light_name')
|
||||||
|
|
||||||
|
def _transform_value(self, mapping: Dict[str, Any], msg, light, default_value: Any) -> Any:
|
||||||
|
"""
|
||||||
|
Transform a MIDI value according to mapping configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mapping: Mapping configuration dictionary
|
||||||
|
msg: MIDI message object
|
||||||
|
light: Light object from hue controller
|
||||||
|
default_value: Default value from MIDI message
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transformed value or None if transformation failed
|
||||||
|
"""
|
||||||
|
if 'value_transform' not in mapping:
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
try:
|
||||||
|
return eval(mapping['value_transform'], {
|
||||||
|
'value': default_value,
|
||||||
|
'msg': msg,
|
||||||
|
'light': light,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error transforming value: {e}")
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
def _send_led_feedback(self, msg, value: Any) -> None:
|
||||||
|
"""
|
||||||
|
Send LED feedback to the MIDI device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: Original MIDI message
|
||||||
|
value: Transformed value (usually boolean)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
led_on = mido.Message('note_on',
|
||||||
|
note=msg.note,
|
||||||
|
velocity=127 if value else 0,
|
||||||
|
channel=msg.channel)
|
||||||
|
self.midi_controller.send_message(led_on)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending LED feedback: {e}")
|
||||||
231
midi_controller.py
Normal file
231
midi_controller.py
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"""
|
||||||
|
MIDI Controller module for MIDI-to-Hue application.
|
||||||
|
Handles MIDI device connections, message processing, and event handling.
|
||||||
|
"""
|
||||||
|
import mido
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from typing import Dict, List, Callable, Optional, Any, Union
|
||||||
|
|
||||||
|
|
||||||
|
class MidiController:
|
||||||
|
"""Manages MIDI device connections and message handling."""
|
||||||
|
|
||||||
|
def __init__(self, midi_device_index: int = 1, reconnect_interval: int = 5):
|
||||||
|
"""
|
||||||
|
Initialize the MIDI controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
midi_device_index: Index of the MIDI device to use
|
||||||
|
reconnect_interval: Seconds between reconnection attempts (if connection lost)
|
||||||
|
"""
|
||||||
|
self.midi_device_index = midi_device_index
|
||||||
|
self.reconnect_interval = reconnect_interval
|
||||||
|
self.port = None
|
||||||
|
self.input_names = []
|
||||||
|
self.output_names = []
|
||||||
|
self.ioport_names = []
|
||||||
|
self.message_handlers = []
|
||||||
|
self._running = False
|
||||||
|
self._reconnect_thread = None
|
||||||
|
self._connection_healthy = False
|
||||||
|
|
||||||
|
# Initialize MIDI
|
||||||
|
self._initialize()
|
||||||
|
|
||||||
|
def _initialize(self) -> None:
|
||||||
|
"""Initialize MIDI ports and discover available devices."""
|
||||||
|
# Get available MIDI inputs/outputs
|
||||||
|
self.input_names = mido.get_input_names()
|
||||||
|
self.output_names = mido.get_output_names()
|
||||||
|
self.ioport_names = mido.get_ioport_names()
|
||||||
|
|
||||||
|
def list_devices(self) -> None:
|
||||||
|
"""Print available MIDI devices."""
|
||||||
|
print("\nAvailable MIDI inputs:")
|
||||||
|
for i, name in enumerate(self.input_names):
|
||||||
|
print(f" {i}: {name}")
|
||||||
|
|
||||||
|
print("\nAvailable MIDI outputs:")
|
||||||
|
for i, name in enumerate(self.output_names):
|
||||||
|
print(f" {i}: {name}")
|
||||||
|
|
||||||
|
print("\nAvailable MIDI I/O ports:")
|
||||||
|
for i, name in enumerate(self.ioport_names):
|
||||||
|
print(f" {i}: {name}")
|
||||||
|
|
||||||
|
def open(self) -> bool:
|
||||||
|
"""Open the configured MIDI port."""
|
||||||
|
try:
|
||||||
|
if not self.input_names:
|
||||||
|
print("No MIDI inputs found. Please connect a MIDI device.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(self.input_names) <= self.midi_device_index:
|
||||||
|
print(f"Error: MIDI device index {self.midi_device_index} out of range.")
|
||||||
|
print(f"Available devices: {len(self.input_names)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
selected_input = self.input_names[self.midi_device_index]
|
||||||
|
print(f"\nOpening MIDI input: {selected_input}")
|
||||||
|
self.port = mido.open_ioport(selected_input)
|
||||||
|
self._connection_healthy = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error opening MIDI device: {e}")
|
||||||
|
self._connection_healthy = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the MIDI port if open."""
|
||||||
|
self._running = False
|
||||||
|
if self._reconnect_thread and self._reconnect_thread.is_alive():
|
||||||
|
self._reconnect_thread.join(timeout=1.0)
|
||||||
|
|
||||||
|
if self.port:
|
||||||
|
self.port.close()
|
||||||
|
self.port = None
|
||||||
|
|
||||||
|
self._connection_healthy = False
|
||||||
|
|
||||||
|
def register_handler(self, handler: Callable) -> None:
|
||||||
|
"""Register a function to handle incoming MIDI messages."""
|
||||||
|
self.message_handlers.append(handler)
|
||||||
|
|
||||||
|
def send_message(self, msg) -> None:
|
||||||
|
"""Send a MIDI message through the current port."""
|
||||||
|
if self.port:
|
||||||
|
self.port.send(msg)
|
||||||
|
else:
|
||||||
|
print("Error: No MIDI port open")
|
||||||
|
|
||||||
|
def _reconnect_monitor(self) -> None:
|
||||||
|
"""
|
||||||
|
Monitor thread that attempts to reconnect if the MIDI connection is lost.
|
||||||
|
"""
|
||||||
|
while self._running:
|
||||||
|
if not self._connection_healthy and not self.port:
|
||||||
|
print("MIDI connection lost. Attempting to reconnect...")
|
||||||
|
# Refresh available devices
|
||||||
|
self._initialize()
|
||||||
|
# Try to open the port again
|
||||||
|
if self.open():
|
||||||
|
print("Successfully reconnected to MIDI device!")
|
||||||
|
else:
|
||||||
|
print(f"Reconnect failed. Retrying in {self.reconnect_interval} seconds...")
|
||||||
|
|
||||||
|
# Wait before checking again
|
||||||
|
time.sleep(self.reconnect_interval)
|
||||||
|
|
||||||
|
def process_messages(self) -> None:
|
||||||
|
"""Start processing MIDI messages, calling registered handlers."""
|
||||||
|
if not self.port:
|
||||||
|
print("Error: No MIDI port open")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Start the reconnect monitor thread
|
||||||
|
self._running = True
|
||||||
|
self._reconnect_thread = threading.Thread(
|
||||||
|
target=self._reconnect_monitor,
|
||||||
|
daemon=True
|
||||||
|
)
|
||||||
|
self._reconnect_thread.start()
|
||||||
|
|
||||||
|
print("Waiting for MIDI messages... Press Ctrl+C to exit.")
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
if not self.port:
|
||||||
|
time.sleep(0.5) # If port is None, wait a bit
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Non-blocking way to check for messages
|
||||||
|
msg = self.port.poll()
|
||||||
|
if msg:
|
||||||
|
print(f"Received: {msg}")
|
||||||
|
# Call all registered handlers
|
||||||
|
for handler in self.message_handlers:
|
||||||
|
handler(msg)
|
||||||
|
# Connection is healthy since we received a message
|
||||||
|
self._connection_healthy = True
|
||||||
|
else:
|
||||||
|
# Small sleep to prevent CPU hogging in the loop
|
||||||
|
time.sleep(0.001)
|
||||||
|
|
||||||
|
except (IOError, AttributeError) as e:
|
||||||
|
# These errors typically indicate a connection problem
|
||||||
|
print(f"MIDI connection error: {e}")
|
||||||
|
self._connection_healthy = False
|
||||||
|
if self.port:
|
||||||
|
try:
|
||||||
|
self.port.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
self.port = None
|
||||||
|
time.sleep(1) # Wait a bit before continuing the loop
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nExiting MIDI processing...")
|
||||||
|
self._running = False
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing MIDI messages: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceMappingManager:
|
||||||
|
"""Manages device-specific MIDI mappings and message types."""
|
||||||
|
|
||||||
|
def __init__(self, device_mappings: Dict[str, Dict[str, str]] = None):
|
||||||
|
"""
|
||||||
|
Initialize with device mappings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_mappings: Dictionary mapping devices to their control mappings
|
||||||
|
"""
|
||||||
|
self.device_mappings = device_mappings or {}
|
||||||
|
self.connected_device = None
|
||||||
|
|
||||||
|
def set_active_device(self, device_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Set the active MIDI device mapping.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Name of the device to activate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if device was found and set, False otherwise
|
||||||
|
"""
|
||||||
|
if device_name in self.device_mappings:
|
||||||
|
self.connected_device = self.device_mappings[device_name]
|
||||||
|
print(f"Activated mapping for device: {device_name}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"No mapping found for device: {device_name}")
|
||||||
|
self.connected_device = {}
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_input_name(self, msg) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the mapped input name for a MIDI message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: MIDI message object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mapped input name or None if no mapping found
|
||||||
|
"""
|
||||||
|
if not self.connected_device or not hasattr(msg, 'type') or not hasattr(msg, 'channel'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_key = None
|
||||||
|
if msg.type == "note_on" and hasattr(msg, 'note'):
|
||||||
|
message_key = f"note_on/{msg.channel}/{msg.note}"
|
||||||
|
elif msg.type == "control_change" and hasattr(msg, 'control'):
|
||||||
|
message_key = f"control_change/{msg.channel}/{msg.control}"
|
||||||
|
|
||||||
|
if message_key and message_key in self.connected_device:
|
||||||
|
return self.connected_device[message_key]
|
||||||
|
|
||||||
|
return None
|
||||||
Loading…
Add table
Reference in a new issue