commit bacc0059c510ae8756ef8b878bcb46895217a2a1 Author: melancholytron Date: Mon Sep 8 12:03:56 2025 -0500 first commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..342a06f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(copy guiarpeggiator_controls.py guiarpeggiator_controls_backup.py)", + "Bash(copy guiarpeggiator_controls_new.py guiarpeggiator_controls.py)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f506b78 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# Claude Code Requirements for Arpeggiator + +## CRITICAL FUNCTIONALITY - DO NOT FORGET + +### Armed State System (ESSENTIAL) +- **MUST have orange "armed" state** when arpeggiator is playing +- When user clicks button during playback: + 1. Button turns ORANGE (armed) + 2. Change does NOT apply immediately + 3. Change waits until pattern end/loop completion + 4. At pattern end: orange button turns GREEN (active), previous green turns gray +- When arpeggiator is stopped: changes apply immediately (no armed state needed) +- **This is critical for live performance** - prevents jarring mid-pattern changes + +### Button Layout Requirements +- NO horizontal spacing between buttons (buttons touch side-by-side) +- Button height should be minimal but readable (16px for 12px font) +- 12px bold font for readability +- Green for active selection, orange for armed selection, gray for inactive + +### GUI Structure +- Quadrant layout: Basic Settings (top-left), Channel Distribution (top-right), Pattern Settings (bottom-left), Timing Settings (bottom-right) +- All buttons must be visible without scrollbars +- Equal-sized quadrants + +## Implementation Notes +- Check `self.arpeggiator.is_playing` before applying changes +- Use `self.arpeggiator.arm_*()` methods when playing +- Connect to `self.arpeggiator.armed_state_changed` signal +- Implement `update_armed_states()` to handle orange→green transitions \ No newline at end of file diff --git a/INSTALL_WINDOWS.md b/INSTALL_WINDOWS.md new file mode 100644 index 0000000..09d0df8 --- /dev/null +++ b/INSTALL_WINDOWS.md @@ -0,0 +1,103 @@ +# Windows Installation Guide + +## Quick Installation + +If you're getting errors with the regular installation, use this Windows-specific installer: + +```bash +python install_windows.py +``` + +This script will: +- Install core packages (PyQt5, numpy, pygame, mido) +- Try multiple MIDI library options +- Create fallbacks if MIDI libraries fail +- Still work in simulator mode even if MIDI fails + +## Manual Installation (if the script doesn't work) + +### 1. Install Core Packages First +```bash +pip install PyQt5>=5.15.0 +pip install numpy>=1.21.0 +pip install pygame>=2.1.0 +pip install mido>=1.2.10 +``` + +### 2. Try MIDI Library Installation +Try these in order until one works: + +**Option A: Latest python-rtmidi** +```bash +pip install python-rtmidi +``` + +**Option B: Specific working version** +```bash +pip install python-rtmidi==1.5.8 +``` + +**Option C: Alternative MIDI library** +```bash +pip install rtmidi-python +``` + +**Option D: Skip MIDI (simulator only)** +If all MIDI libraries fail, you can still use the application in simulator mode with built-in audio synthesis. + +## Common Issues and Solutions + +### Issue 1: Cython Compilation Error +``` +Error compiling Cython file... Cannot assign type 'void (*)'... +``` +**Solution:** Install Visual Studio Build Tools or use pre-compiled wheel: +```bash +pip install --only-binary=python-rtmidi python-rtmidi +``` + +### Issue 2: No MIDI Devices Found +**Solutions:** +1. Use simulator mode (works without any MIDI hardware) +2. Check Windows MIDI device drivers +3. Try different USB ports for MIDI devices + +### Issue 3: Audio Not Working +**Solutions:** +1. Check Windows audio device settings +2. Try different audio buffer sizes in the app settings +3. Make sure no other applications are using audio exclusively + +## Verify Installation + +Run this to test if everything works: +```bash +python -c " +try: + import PyQt5; print('✓ PyQt5 OK') + import numpy; print('✓ NumPy OK') + import pygame; print('✓ Pygame OK') + import mido; print('✓ Mido OK') + try: + import rtmidi; print('✓ RTMIDI OK') + except: print('⚠ RTMIDI failed - simulator mode only') + print('Ready to run!') +except Exception as e: + print(f'✗ Error: {e}') +" +``` + +## Running the Application + +Once installed, run: +```bash +python run.py +``` + +The application will start in simulator mode by default, which works without any MIDI hardware and provides: +- Internal audio synthesis +- Visual lighting simulation +- All arpeggiator functionality +- Complete GUI interface + +For hardware MIDI, select "Hardware Mode" in the Output Controls tab. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b65273 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# MIDI Arpeggiator - Lighting Controller + +A modular MIDI arpeggiator application with integrated lighting control and Native Instruments Maschine support. + +## Features + +### Core Functionality +- **FL Studio-style Arpeggiator**: Complete arpeggiator with up/down/random patterns, scales, swing, gate control +- **Multi-Synth Support**: Control up to 16 synthesizers simultaneously with individual voice management +- **Volume/Brightness Patterns**: Dynamic volume patterns that control both audio levels and lighting brightness +- **Routing Patterns**: Spatial routing patterns for creating lighting effects (bounce, wave, cascade, etc.) + +### Audio & MIDI +- **Dual Output Modes**: Built-in simulator with audio synthesis or external hardware MIDI output +- **Program Change Support**: Individual instrument selection per channel with General MIDI compatibility +- **Voice Management**: 3-voice polyphony per synthesizer with intelligent voice stealing +- **Real-time MIDI Processing**: High-precision timing for tight synchronization + +### Lighting Control +- **MIDI-Controlled Lighting**: Each synth doubles as a lighting fixture controlled by MIDI notes +- **Visual Patterns**: Specialized patterns designed for lighting effects (ripple, spotlight, cascade) +- **Brightness Control**: Volume and velocity control lighting brightness in real-time +- **Pattern Coordination**: Synchronize musical and visual patterns for cohesive shows + +### Hardware Integration +- **Native Instruments Maschine**: Full integration with Maschine controllers for hands-on control +- **Real-time Control**: Pads, encoders, and buttons mapped to arpeggiator parameters +- **LED Feedback**: Visual feedback on Maschine pads showing current state and activity +- **Multiple Control Modes**: Switch between note input, channel control, and pattern selection modes + +### User Interface +- **Modular GUI**: Clean PyQt5 interface with tabbed control panels +- **Real-time Visualization**: Visual representation of synth array with lighting simulation +- **Preset System**: Save and recall complete system configurations +- **Configuration Management**: Persistent settings with intelligent defaults + +## Installation + +### Requirements +- Python 3.7+ +- PyQt5 +- pygame (for audio synthesis) +- python-rtmidi (for MIDI I/O) +- mido (for MIDI message handling) +- numpy (for audio processing) + +### Setup +1. Clone or download the project +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +3. Run the application: + ```bash + python run.py + ``` + +## Module Structure + +### Core Modules (`core/`) +- **`midi_channel_manager.py`**: MIDI channel management and voice allocation +- **`arpeggiator_engine.py`**: Main arpeggiator logic and pattern generation +- **`volume_pattern_engine.py`**: Volume and brightness pattern generation +- **`synth_router.py`**: Note routing and spatial pattern management +- **`output_manager.py`**: MIDI output handling (simulator vs hardware) + +### GUI Modules (`gui/`) +- **`main_window.py`**: Main application window and layout +- **`arpeggiator_controls.py`**: Arpeggiator parameter controls +- **`channel_controls.py`**: MIDI channel and instrument management +- **`volume_controls.py`**: Volume/brightness pattern controls +- **`simulator_display.py`**: Visual synth array display +- **`output_controls.py`**: Output mode and device selection +- **`preset_controls.py`**: Preset management interface + +### Simulator Module (`simulator/`) +- **`simulator_engine.py`**: Internal audio synthesis and lighting simulation + +### Maschine Integration (`maschine/`) +- **`maschine_interface.py`**: Low-level Maschine MIDI communication +- **`maschine_controller.py`**: High-level Maschine integration controller + +### Configuration (`config/`) +- **`configuration.py`**: Application settings and persistence + +## Usage + +### Basic Operation +1. **Select Output Mode**: Choose between "Simulator" (internal audio) or "Hardware" (external MIDI) +2. **Configure Channels**: Set number of active synths and assign instruments +3. **Set Arpeggiator Parameters**: Choose root note, scale, pattern, tempo, etc. +4. **Configure Patterns**: Select routing patterns for spatial effects and volume patterns for brightness +5. **Play Notes**: Use computer keyboard, MIDI controller, or Maschine to trigger arpeggiator + +### Maschine Integration +1. Connect Native Instruments Maschine hardware +2. Enable Maschine integration in settings +3. Use pads to trigger notes, encoders for real-time parameter control +4. Switch pad modes for different control functions +5. LED feedback shows current state and activity + +### Lighting Control +- Each synthesizer channel corresponds to a lighting fixture +- Note velocity and channel volume control brightness +- Volume patterns create dynamic lighting effects +- Routing patterns determine which lights activate for each note + +### Preset Management +- Save current settings as named presets +- Load presets for quick configuration changes +- Import/export presets to share configurations +- Automatic preset backup and recovery + +## Configuration + +The application creates a `config.json` file with all settings. Key configuration sections: + +- **Audio**: Sample rate, buffer size, master volume +- **MIDI**: Default devices and connection settings +- **Arpeggiator**: Default parameters and ranges +- **Channels**: Default synth count and instruments +- **Volume Patterns**: Default patterns and ranges +- **Maschine**: Hardware integration settings +- **Interface**: Theme and display preferences + +## Troubleshooting + +### Audio Issues +- Check audio device selection in system settings +- Try different buffer sizes if experiencing dropouts +- Ensure sample rate matches system audio settings + +### MIDI Issues +- Refresh MIDI device list if devices not appearing +- Check MIDI device connections and drivers +- Use "Panic" button to stop stuck notes + +### Maschine Issues +- Ensure Maschine is in MIDI mode, not Controller mode +- Check USB connection and drivers +- Try disconnecting/reconnecting if not responding + +## Architecture Notes + +The application uses a modular architecture with clear separation of concerns: + +- **Core modules** handle the core arpeggiator logic and MIDI processing +- **GUI modules** provide the user interface without business logic +- **Simulator module** provides standalone audio synthesis +- **Maschine module** handles hardware integration +- **Configuration module** manages persistence and settings + +This design makes the application easy to understand, maintain, and extend with new features. + +## License + +This project is open source. See LICENSE file for details. \ No newline at end of file diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md new file mode 100644 index 0000000..cdb5336 --- /dev/null +++ b/USAGE_GUIDE.md @@ -0,0 +1,160 @@ +# MIDI Arpeggiator - Usage Guide + +## 🎹 How to Use the Arpeggiator + +### Quick Start +1. **Run the application**: `python run.py` +2. **Play notes**: Use keyboard keys (see below) or click Play for test notes +3. **Start arpeggiator**: Press SPACEBAR or click Play button +4. **Stop arpeggiator**: Press SPACEBAR again or click Stop button + +### 🎵 Playing Notes with Computer Keyboard + +Use these keys to play notes (like a piano): +``` + W E T Y U + A S D F G H J K L ; +``` + +**Key Mapping:** +- **A** = C (Middle C - note 60) +- **W** = C# (sharp) +- **S** = D +- **E** = D# (sharp) +- **D** = E +- **F** = F +- **T** = F# (sharp) +- **G** = G +- **Y** = G# (sharp) +- **H** = A +- **U** = A# (sharp) +- **J** = B +- **K** = C (next octave) + +**Controls:** +- **SPACEBAR** = Start/Stop arpeggiator +- **Hold multiple keys** = Play chords +- **Release keys** = Stop notes + +### 🎛️ Simulator Mode (Default) +- **Built-in audio synthesis** - hear sounds directly from the app +- **Visual lighting display** - see synth array with brightness patterns +- **No external hardware needed** - perfect for testing and development + +**What you should hear/see:** +- ✅ Audio when notes are triggered by the arpeggiator +- ✅ Visual synths lighting up in patterns +- ✅ Different instruments per channel +- ✅ Volume patterns creating lighting effects + +### 🔌 Hardware MIDI Mode +Switch to hardware mode in the "Output" tab: +1. Click **"Hardware Mode"** radio button +2. Select your MIDI device from dropdown +3. Connect to external synthesizer/software + +**Available MIDI Devices** (from your system): +- Microsoft GS Wavetable Synth (built-in Windows synth) +- LoopBe Internal MIDI (virtual MIDI cable) +- Other virtual MIDI devices + +### 🎚️ Controls + +#### Arpeggiator Tab +- **Root Note**: Starting note (default: C4) +- **Scale**: Musical scale (Major, Minor, Dorian, etc.) +- **Pattern**: Up, Down, Up-Down, Random, etc. +- **Octave Range**: 1-4 octaves +- **Tempo**: 40-200 BPM +- **Note Speed**: 1/32 to whole notes +- **Gate**: Note length (10%-200%) +- **Swing**: Timing swing (-100% to +100%) +- **Velocity**: Note velocity (1-127) + +#### Channels Tab +- **Active Synths**: 1-16 synths +- **Instruments**: Individual GM instruments per channel +- **Global Instrument**: Apply same instrument to all channels +- **Voice Monitoring**: See active voices per channel + +#### Volume/Lighting Tab +- **Pattern**: Volume patterns (Static, Swell, Breathing, Wave, etc.) +- **Speed**: Pattern speed multiplier +- **Intensity**: Pattern intensity +- **Global Ranges**: Min/max volume and velocity for all channels +- **Individual Ranges**: Per-channel volume and velocity ranges + +#### Output Tab +- **Mode Selection**: Simulator vs Hardware +- **MIDI Device**: Select hardware MIDI output +- **Test Output**: Send test note +- **Panic**: All notes off + +#### Presets Tab +- **Save/Load**: Store and recall complete configurations +- **Import/Export**: Share presets with others +- **Rename/Delete**: Manage preset library + +### 🎪 Lighting Effects + +The arpeggiator creates synchronized lighting effects: + +**Routing Patterns** (which synths play): +- **Bounce**: Notes bounce between first and last synths +- **Cycle**: Notes rotate through all synths +- **Wave**: Smooth wave motion across synths +- **Random**: Random synth selection for sparkle effects + +**Volume Patterns** (brightness control): +- **Swell**: Gradual volume increase/decrease +- **Breathing**: Rhythmic in/out like breathing +- **Wave**: Sine wave across channels +- **Cascade**: Volume cascade across synths +- **Random Sparkle**: Random volume variations + +### 🔧 Troubleshooting + +#### No Audio in Simulator Mode +- Check Windows audio settings +- Ensure no other app is using audio exclusively +- Try adjusting master volume in simulator controls +- Check audio device in Windows sound settings + +#### No Sound in Hardware Mode +- **Microsoft GS Wavetable Synth**: Check Windows volume mixer - might be muted +- **Virtual MIDI**: Make sure receiving software synth is running +- **External Hardware**: Check MIDI cables and device settings +- Use "Test Output" button to verify MIDI connection + +#### Keyboard Input Not Working +- Make sure main window has focus (click on it) +- Keys only work when window is active +- Try clicking in the window then pressing keys + +#### Performance Issues +- Reduce active synth count if sluggish +- Lower volume pattern update rate +- Close other resource-intensive applications + +### 💡 Tips for Best Results + +1. **Start Simple**: Use default settings, add a few notes, press Play +2. **Experiment with Patterns**: Try different arp patterns and routing patterns +3. **Layer Effects**: Combine musical patterns with volume patterns for cool visuals +4. **Use Presets**: Save configurations you like for quick recall +5. **Hardware Setup**: Use virtual MIDI cables to connect to software synths like VCV Rack, Ableton Live, etc. + +### 🎨 Art Installation Usage + +For your art installation: +1. Set **Active Synths** to match your physical synth count (1-16) +2. Configure **Routing Patterns** for spatial lighting effects +3. Set **Volume Patterns** for dynamic brightness +4. Use **Hardware Mode** to send MIDI to your synth array +5. **Maschine Integration** for live control (when connected) + +Each synth channel will control both the audio synthesis and lighting brightness simultaneously! + +--- + +**Have fun creating amazing arpeggiated lighting shows!** 🌟 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..43637a6 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# Empty file to make this directory a Python package \ No newline at end of file diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..a5651f5 Binary files /dev/null and b/__pycache__/main.cpython-310.pyc differ diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..04a8470 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# Configuration module - Application settings and configuration management \ No newline at end of file diff --git a/config/__pycache__/__init__.cpython-310.pyc b/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..fa90d94 Binary files /dev/null and b/config/__pycache__/__init__.cpython-310.pyc differ diff --git a/config/__pycache__/configuration.cpython-310.pyc b/config/__pycache__/configuration.cpython-310.pyc new file mode 100644 index 0000000..3e18c76 Binary files /dev/null and b/config/__pycache__/configuration.cpython-310.pyc differ diff --git a/config/configuration.py b/config/configuration.py new file mode 100644 index 0000000..822829b --- /dev/null +++ b/config/configuration.py @@ -0,0 +1,335 @@ +""" +Configuration Module + +Manages application configuration, settings persistence, and defaults. +""" + +import json +import os +from typing import Dict, Any, Optional + +class Configuration: + """ + Application configuration manager. + Handles loading, saving, and managing application settings. + """ + + DEFAULT_CONFIG = { + "version": "1.0", + "window": { + "width": 1200, + "height": 800, + "maximize": False, + "remember_position": True, + "x": 100, + "y": 100 + }, + "audio": { + "sample_rate": 22050, + "buffer_size": 512, + "master_volume": 0.8, + "enabled": True + }, + "midi": { + "default_output": "", + "default_mode": "simulator", + "auto_connect": True + }, + "arpeggiator": { + "default_root_note": 60, # Middle C + "default_scale": "major", + "default_pattern": "up", + "default_tempo": 120.0, + "default_octave_range": 1, + "default_note_speed": "1/8", + "default_gate": 1.0, + "default_swing": 0.0, + "default_velocity": 80 + }, + "channels": { + "default_synth_count": 8, + "default_instrument": 0, # Piano + "max_voices_per_synth": 3 + }, + "volume_patterns": { + "default_pattern": "static", + "default_speed": 1.0, + "default_intensity": 1.0, + "default_min_volume": 0.2, + "default_max_volume": 1.0, + "default_min_velocity": 40, + "default_max_velocity": 127 + }, + "simulator": { + "default_brightness": 1.0, + "lighting_enabled": True, + "animation_speed": 16 # ms between updates + }, + "maschine": { + "enabled": False, + "device_name": "", + "auto_connect": True, + "pad_sensitivity": 1.0, + "encoder_sensitivity": 1.0 + }, + "presets": { + "auto_save_current": True, + "remember_last_preset": True, + "last_preset": "" + }, + "interface": { + "theme": "dark", + "font_size": 10, + "show_tooltips": True, + "confirm_destructive_actions": True + } + } + + def __init__(self, config_file: str = "config.json"): + self.config_file = config_file + self.config = self.DEFAULT_CONFIG.copy() + self.load_config() + + def load_config(self) -> bool: + """Load configuration from file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r') as f: + loaded_config = json.load(f) + + # Merge with defaults (preserves new default settings) + self._merge_config(self.config, loaded_config) + return True + except Exception as e: + print(f"Error loading configuration: {e}") + print("Using default configuration") + + return False + + def save_config(self) -> bool: + """Save current configuration to file""" + try: + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except Exception as e: + print(f"Error saving configuration: {e}") + return False + + def _merge_config(self, base: dict, update: dict): + """Recursively merge configuration dictionaries""" + for key, value in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._merge_config(base[key], value) + else: + base[key] = value + + def get(self, path: str, default: Any = None) -> Any: + """ + Get configuration value using dot notation. + Example: get("window.width") returns config["window"]["width"] + """ + keys = path.split('.') + value = self.config + + try: + for key in keys: + value = value[key] + return value + except (KeyError, TypeError): + return default + + def set(self, path: str, value: Any) -> bool: + """ + Set configuration value using dot notation. + Example: set("window.width", 1024) + """ + keys = path.split('.') + config_ref = self.config + + try: + # Navigate to parent dictionary + for key in keys[:-1]: + if key not in config_ref: + config_ref[key] = {} + config_ref = config_ref[key] + + # Set the value + config_ref[keys[-1]] = value + return True + except Exception as e: + print(f"Error setting config value {path}: {e}") + return False + + def get_section(self, section: str) -> Dict[str, Any]: + """Get entire configuration section""" + return self.config.get(section, {}) + + def set_section(self, section: str, values: Dict[str, Any]): + """Set entire configuration section""" + if section not in self.config: + self.config[section] = {} + self.config[section].update(values) + + def reset_to_defaults(self, section: Optional[str] = None): + """Reset configuration to defaults""" + if section: + if section in self.DEFAULT_CONFIG: + self.config[section] = self.DEFAULT_CONFIG[section].copy() + else: + self.config = self.DEFAULT_CONFIG.copy() + + def get_window_settings(self) -> Dict[str, Any]: + """Get window-related settings""" + return self.get_section("window") + + def save_window_settings(self, width: int, height: int, x: int, y: int, maximized: bool = False): + """Save window position and size""" + window_settings = { + "width": width, + "height": height, + "x": x, + "y": y, + "maximize": maximized + } + self.set_section("window", window_settings) + self.save_config() + + def get_audio_settings(self) -> Dict[str, Any]: + """Get audio-related settings""" + return self.get_section("audio") + + def get_midi_settings(self) -> Dict[str, Any]: + """Get MIDI-related settings""" + return self.get_section("midi") + + def get_arpeggiator_defaults(self) -> Dict[str, Any]: + """Get arpeggiator default settings""" + return self.get_section("arpeggiator") + + def get_channel_defaults(self) -> Dict[str, Any]: + """Get channel default settings""" + return self.get_section("channels") + + def get_volume_pattern_defaults(self) -> Dict[str, Any]: + """Get volume pattern default settings""" + return self.get_section("volume_patterns") + + def get_simulator_settings(self) -> Dict[str, Any]: + """Get simulator settings""" + return self.get_section("simulator") + + def get_maschine_settings(self) -> Dict[str, Any]: + """Get Maschine integration settings""" + return self.get_section("maschine") + + def get_preset_settings(self) -> Dict[str, Any]: + """Get preset management settings""" + return self.get_section("presets") + + def get_interface_settings(self) -> Dict[str, Any]: + """Get interface settings""" + return self.get_section("interface") + + def should_auto_save_preset(self) -> bool: + """Check if presets should be auto-saved""" + return self.get("presets.auto_save_current", True) + + def should_remember_last_preset(self) -> bool: + """Check if last preset should be remembered""" + return self.get("presets.remember_last_preset", True) + + def get_last_preset(self) -> str: + """Get name of last used preset""" + return self.get("presets.last_preset", "") + + def set_last_preset(self, preset_name: str): + """Set name of last used preset""" + self.set("presets.last_preset", preset_name) + self.save_config() + + def should_confirm_destructive_actions(self) -> bool: + """Check if destructive actions should be confirmed""" + return self.get("interface.confirm_destructive_actions", True) + + def get_theme(self) -> str: + """Get current theme""" + return self.get("interface.theme", "dark") + + def export_config(self, file_path: str) -> bool: + """Export configuration to a different file""" + try: + with open(file_path, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except Exception as e: + print(f"Error exporting configuration: {e}") + return False + + def import_config(self, file_path: str) -> bool: + """Import configuration from a file""" + try: + if os.path.exists(file_path): + with open(file_path, 'r') as f: + imported_config = json.load(f) + + # Validate and merge + self._merge_config(self.config, imported_config) + self.save_config() + return True + except Exception as e: + print(f"Error importing configuration: {e}") + + return False + + def validate_config(self) -> bool: + """Validate configuration values""" + valid = True + + # Validate window settings + window = self.get_section("window") + if window.get("width", 0) < 800: + self.set("window.width", 1200) + valid = False + + if window.get("height", 0) < 600: + self.set("window.height", 800) + valid = False + + # Validate audio settings + audio = self.get_section("audio") + if audio.get("sample_rate", 0) not in [22050, 44100, 48000]: + self.set("audio.sample_rate", 22050) + valid = False + + if not (0.0 <= audio.get("master_volume", 1.0) <= 1.0): + self.set("audio.master_volume", 0.8) + valid = False + + # Validate arpeggiator settings + arp = self.get_section("arpeggiator") + if not (0 <= arp.get("default_root_note", 60) <= 127): + self.set("arpeggiator.default_root_note", 60) + valid = False + + if not (40 <= arp.get("default_tempo", 120) <= 200): + self.set("arpeggiator.default_tempo", 120.0) + valid = False + + # Validate channel settings + channels = self.get_section("channels") + if not (1 <= channels.get("default_synth_count", 8) <= 16): + self.set("channels.default_synth_count", 8) + valid = False + + return valid + + def get_config_info(self) -> Dict[str, Any]: + """Get configuration metadata""" + return { + "version": self.config.get("version", "Unknown"), + "file_path": self.config_file, + "file_exists": os.path.exists(self.config_file), + "file_size": os.path.getsize(self.config_file) if os.path.exists(self.config_file) else 0, + "sections": list(self.config.keys()) + } \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..1ba3081 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# Core module - Contains the main arpeggiator logic and MIDI handling \ No newline at end of file diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4ed7c03 Binary files /dev/null and b/core/__pycache__/__init__.cpython-310.pyc differ diff --git a/core/__pycache__/arpeggiator_engine.cpython-310.pyc b/core/__pycache__/arpeggiator_engine.cpython-310.pyc new file mode 100644 index 0000000..8d587d4 Binary files /dev/null and b/core/__pycache__/arpeggiator_engine.cpython-310.pyc differ diff --git a/core/__pycache__/midi_channel_manager.cpython-310.pyc b/core/__pycache__/midi_channel_manager.cpython-310.pyc new file mode 100644 index 0000000..12d70b0 Binary files /dev/null and b/core/__pycache__/midi_channel_manager.cpython-310.pyc differ diff --git a/core/__pycache__/output_manager.cpython-310.pyc b/core/__pycache__/output_manager.cpython-310.pyc new file mode 100644 index 0000000..22091ad Binary files /dev/null and b/core/__pycache__/output_manager.cpython-310.pyc differ diff --git a/core/__pycache__/synth_router.cpython-310.pyc b/core/__pycache__/synth_router.cpython-310.pyc new file mode 100644 index 0000000..4a84a36 Binary files /dev/null and b/core/__pycache__/synth_router.cpython-310.pyc differ diff --git a/core/__pycache__/volume_pattern_engine.cpython-310.pyc b/core/__pycache__/volume_pattern_engine.cpython-310.pyc new file mode 100644 index 0000000..a3ccb08 Binary files /dev/null and b/core/__pycache__/volume_pattern_engine.cpython-310.pyc differ diff --git a/core/arpeggiator_engine.py b/core/arpeggiator_engine.py new file mode 100644 index 0000000..be1f6cc --- /dev/null +++ b/core/arpeggiator_engine.py @@ -0,0 +1,630 @@ +""" +Arpeggiator Engine Module + +Core arpeggiator logic with FL Studio-style functionality. +Generates arpeggio patterns, handles timing, and integrates with routing and volume systems. +""" + +import time +import math +import threading +from typing import Dict, List, Optional, Tuple, Set +from PyQt5.QtCore import QObject, pyqtSignal, QTimer + +from .midi_channel_manager import MIDIChannelManager +from .synth_router import SynthRouter +from .volume_pattern_engine import VolumePatternEngine +from .output_manager import OutputManager + +class ArpeggiatorEngine(QObject): + """ + Main arpeggiator engine with FL Studio-style functionality. + Handles pattern generation, timing, scale processing, and note scheduling. + """ + + # Signals + note_triggered = pyqtSignal(int, int, int, float) # channel, note, velocity, duration + pattern_step = pyqtSignal(int) # current step + tempo_changed = pyqtSignal(float) # BPM + playing_state_changed = pyqtSignal(bool) # is_playing + armed_state_changed = pyqtSignal() # armed state changed + + # Arpeggio pattern types (FL Studio style) + PATTERN_TYPES = [ + "up", "down", "up_down", "down_up", "random", + "note_order", "chord", "random_chord", "custom" + ] + + # Channel distribution patterns (how notes are spread across channels) + CHANNEL_DISTRIBUTION_PATTERNS = [ + "up", "down", "up_down", "bounce", "random", "cycle", + "alternating", "single_channel" + ] + + # Musical scales + SCALES = { + "chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + "major": [0, 2, 4, 5, 7, 9, 11], + "minor": [0, 2, 3, 5, 7, 8, 10], + "dorian": [0, 2, 3, 5, 7, 9, 10], + "phrygian": [0, 1, 3, 5, 7, 8, 10], + "lydian": [0, 2, 4, 6, 7, 9, 11], + "mixolydian": [0, 2, 4, 5, 7, 9, 10], + "locrian": [0, 1, 3, 5, 6, 8, 10], + "harmonic_minor": [0, 2, 3, 5, 7, 8, 11], + "melodic_minor": [0, 2, 3, 5, 7, 9, 11], + "pentatonic_major": [0, 2, 4, 7, 9], + "pentatonic_minor": [0, 3, 5, 7, 10], + "blues": [0, 3, 5, 6, 7, 10] + } + + # Note speeds (as fractions of a beat) + NOTE_SPEEDS = { + "1/32": 1/32, "1/16": 1/16, "1/8": 1/8, "1/4": 1/4, + "1/2": 1/2, "1/1": 1, "2/1": 2 + } + + def __init__(self, channel_manager: MIDIChannelManager, synth_router: SynthRouter, + volume_engine: VolumePatternEngine, output_manager: OutputManager): + super().__init__() + + self.channel_manager = channel_manager + self.synth_router = synth_router + self.volume_engine = volume_engine + self.output_manager = output_manager + + # Arpeggiator settings + self.root_note = 60 # Middle C + self.scale = "major" + self.pattern_type = "up" + self.octave_range = 1 # 1-4 octaves + self.note_speed = "1/8" # Note duration + self.gate = 1.0 # Note length as fraction of step (0.1-2.0) + self.swing = 0.0 # Swing amount (-100% to +100%) + self.velocity = 80 # Base velocity + + # Channel distribution settings + self.channel_distribution = "up" # How notes are distributed across channels + self.channel_position = 0 # Current position in channel distribution pattern + + # Armed state system for pattern-end changes + self.armed_root_note = None + self.armed_scale = None + self.armed_pattern_type = None + self.armed_channel_distribution = None + + # Pattern loop tracking + self.pattern_loops_completed = 0 + self.pattern_start_position = 0 + + # Playback state + self.is_playing = False + self.tempo = 120.0 # BPM + self.current_step = 0 + self.pattern_length = 0 + + # Input notes (what's being held down) + self.held_notes: Set[int] = set() + self.input_chord: List[int] = [] + + # Generated pattern + self.current_pattern: List[int] = [] + self.pattern_position = 0 + + # Timing + self.last_step_time = 0.0 + self.step_duration = 0.0 # Seconds per step + self.next_step_time = 0.0 + + # Active notes tracking for note-off + self.active_notes: Dict[Tuple[int, int], float] = {} # (channel, note) -> end_time + + # Threading for timing precision + self.timing_thread = None + self.stop_timing = False + + # Setup timing calculation + self.calculate_step_duration() + + # Setup update timer + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update) + self.update_timer.start(1) # 1ms updates for precise timing + + def set_root_note(self, note: int): + """Set root note (0-127)""" + if 0 <= note <= 127: + self.root_note = note + self.regenerate_pattern() + + def arm_root_note(self, note: int): + """Arm a root note to change at pattern end""" + if 0 <= note <= 127: + self.armed_root_note = note + self.armed_state_changed.emit() + + def clear_armed_root_note(self): + """Clear armed root note""" + self.armed_root_note = None + self.armed_state_changed.emit() + + def set_scale(self, scale_name: str): + """Set musical scale""" + if scale_name in self.SCALES: + self.scale = scale_name + self.regenerate_pattern() + + def arm_scale(self, scale_name: str): + """Arm a scale to change at pattern end""" + if scale_name in self.SCALES: + self.armed_scale = scale_name + self.armed_state_changed.emit() + + def clear_armed_scale(self): + """Clear armed scale""" + self.armed_scale = None + self.armed_state_changed.emit() + + def set_pattern_type(self, pattern_type: str): + """Set arpeggio pattern type""" + if pattern_type in self.PATTERN_TYPES: + self.pattern_type = pattern_type + self.regenerate_pattern() + + def arm_pattern_type(self, pattern_type: str): + """Arm a pattern type to change at pattern end""" + if pattern_type in self.PATTERN_TYPES: + self.armed_pattern_type = pattern_type + self.armed_state_changed.emit() + + def clear_armed_pattern_type(self): + """Clear armed pattern type""" + self.armed_pattern_type = None + self.armed_state_changed.emit() + + def set_octave_range(self, octaves: int): + """Set octave range (1-4)""" + if 1 <= octaves <= 4: + self.octave_range = octaves + self.regenerate_pattern() + + def set_note_speed(self, speed: str): + """Set note speed""" + if speed in self.NOTE_SPEEDS: + self.note_speed = speed + self.calculate_step_duration() + + def set_gate(self, gate: float): + """Set gate (note length) 0.1-2.0""" + self.gate = max(0.1, min(2.0, gate)) + + def set_swing(self, swing: float): + """Set swing amount -1.0 to 1.0""" + self.swing = max(-1.0, min(1.0, swing)) + + def set_velocity(self, velocity: int): + """Set base velocity 0-127""" + self.velocity = max(0, min(127, velocity)) + + def set_channel_distribution(self, distribution: str): + """Set channel distribution pattern""" + if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS: + self.channel_distribution = distribution + self.channel_position = 0 # Reset position + + def arm_channel_distribution(self, distribution: str): + """Arm a distribution pattern to change at pattern end""" + if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS: + self.armed_channel_distribution = distribution + self.armed_state_changed.emit() + + def clear_armed_channel_distribution(self): + """Clear armed distribution pattern""" + self.armed_channel_distribution = None + self.armed_state_changed.emit() + + def set_tempo(self, bpm: float): + """Set tempo in BPM""" + if 40 <= bpm <= 200: + self.tempo = bpm + self.calculate_step_duration() + self.tempo_changed.emit(bpm) + + def calculate_step_duration(self): + """Calculate time between steps based on tempo and note speed""" + beats_per_second = self.tempo / 60.0 + note_duration = self.NOTE_SPEEDS[self.note_speed] + self.step_duration = note_duration / beats_per_second + + def note_on(self, note: int): + """Register a note being pressed""" + self.held_notes.add(note) + self.update_input_chord() + self.regenerate_pattern() + + def note_off(self, note: int): + """Register a note being released""" + self.held_notes.discard(note) + self.update_input_chord() + if not self.held_notes: + self.stop() + else: + self.regenerate_pattern() + + def update_input_chord(self): + """Update the chord from held notes""" + self.input_chord = sorted(list(self.held_notes)) + + def start(self): + """Start arpeggiator playback""" + if not self.held_notes: + return False + + if not self.is_playing: + self.is_playing = True + self.current_step = 0 + self.pattern_position = 0 + self.last_step_time = time.time() + self.next_step_time = self.last_step_time + self.step_duration + self.playing_state_changed.emit(True) + return True + return False + + def stop(self): + """Stop arpeggiator playback""" + if self.is_playing: + self.is_playing = False + self.all_notes_off() + self.playing_state_changed.emit(False) + + def all_notes_off(self): + """Send note off for all active notes""" + current_time = time.time() + for (channel, note) in list(self.active_notes.keys()): + self.output_manager.send_note_off(channel, note) + self.channel_manager.release_voice(channel, note) + self.active_notes.clear() + + def regenerate_pattern(self): + """Regenerate arpeggio pattern based on current settings""" + if not self.input_chord: + self.current_pattern = [] + return + + # Get scale notes for the key + scale_intervals = self.SCALES[self.scale] + + # Generate pattern based on type + if self.pattern_type == "up": + self.current_pattern = self._generate_up_pattern() + elif self.pattern_type == "down": + self.current_pattern = self._generate_down_pattern() + elif self.pattern_type == "up_down": + self.current_pattern = self._generate_up_down_pattern() + elif self.pattern_type == "down_up": + self.current_pattern = self._generate_down_up_pattern() + elif self.pattern_type == "random": + self.current_pattern = self._generate_random_pattern() + elif self.pattern_type == "note_order": + self.current_pattern = self._generate_note_order_pattern() + elif self.pattern_type == "chord": + self.current_pattern = self._generate_chord_pattern() + elif self.pattern_type == "random_chord": + self.current_pattern = self._generate_random_chord_pattern() + + self.pattern_length = len(self.current_pattern) + self.pattern_position = 0 + + def _generate_scale_notes(self) -> List[int]: + """Generate all scale notes within octave range""" + scale_intervals = self.SCALES[self.scale] + notes = [] + + # Start from root note + base_octave = self.root_note // 12 + root_in_octave = self.root_note % 12 + + # Find closest scale degree to root + closest_degree = 0 + min_distance = 12 + for i, interval in enumerate(scale_intervals): + distance = abs((root_in_octave - interval) % 12) + if distance < min_distance: + min_distance = distance + closest_degree = i + + # Generate notes across octave range + for octave in range(self.octave_range): + for degree, interval in enumerate(scale_intervals): + note = base_octave * 12 + root_in_octave + interval + (octave * 12) + if 0 <= note <= 127: + notes.append(note) + + return sorted(notes) + + def _generate_up_pattern(self) -> List[int]: + """Generate ascending arpeggio pattern""" + scale_notes = self._generate_scale_notes() + return scale_notes + + def _generate_down_pattern(self) -> List[int]: + """Generate descending arpeggio pattern""" + scale_notes = self._generate_scale_notes() + return list(reversed(scale_notes)) + + def _generate_up_down_pattern(self) -> List[int]: + """Generate up then down pattern""" + scale_notes = self._generate_scale_notes() + # Up, then down (avoiding duplicate at top) + return scale_notes + list(reversed(scale_notes[:-1])) + + def _generate_down_up_pattern(self) -> List[int]: + """Generate down then up pattern""" + scale_notes = self._generate_scale_notes() + # Down, then up (avoiding duplicate at bottom) + return list(reversed(scale_notes)) + scale_notes[1:] + + def _generate_random_pattern(self) -> List[int]: + """Generate random pattern from scale notes""" + import random + scale_notes = self._generate_scale_notes() + pattern_length = max(8, len(scale_notes)) + return [random.choice(scale_notes) for _ in range(pattern_length)] + + def _generate_note_order_pattern(self) -> List[int]: + """Generate pattern in the order notes were played""" + return self.input_chord * self.octave_range + + def _generate_chord_pattern(self) -> List[int]: + """Generate chord pattern (all notes together, represented as sequence)""" + return self.input_chord + + def _generate_random_chord_pattern(self) -> List[int]: + """Generate pattern with random chord combinations""" + import random + import itertools + + if len(self.input_chord) < 2: + return self.input_chord + + # Generate various chord combinations + patterns = [] + + # Individual notes + patterns.extend(self.input_chord) + + # Pairs + for pair in itertools.combinations(self.input_chord, 2): + patterns.extend(pair) + + # Full chord + patterns.extend(self.input_chord) + + return patterns + + def _get_next_channel(self) -> int: + """Get next channel based on distribution pattern""" + active_channels = self.channel_manager.get_active_channels() + if not active_channels: + return 1 + + if self.channel_distribution == "single_channel": + return active_channels[0] + + elif self.channel_distribution == "up": + # 1, 2, 3, 4, 5, 6... + channel_idx = self.channel_position % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "down": + # 6, 5, 4, 3, 2, 1... + channel_idx = (len(active_channels) - 1 - self.channel_position) % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "up_down": + # 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3... + cycle_length = (len(active_channels) - 1) * 2 + pos = self.channel_position % cycle_length + + if pos < len(active_channels): + channel_idx = pos + else: + channel_idx = len(active_channels) - 2 - (pos - len(active_channels)) + + self.channel_position += 1 + return active_channels[max(0, channel_idx)] + + elif self.channel_distribution == "bounce": + # 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3... (same as up_down but clearer name) + return self._get_bounce_channel(active_channels) + + elif self.channel_distribution == "random": + import random + return random.choice(active_channels) + + elif self.channel_distribution == "cycle": + # Simple cycle through channels + channel_idx = self.channel_position % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "alternating": + # Alternate between first and last, second and second-to-last, etc. + half_point = len(active_channels) // 2 + if self.channel_position % 2 == 0: + # Even steps: use first half + idx = (self.channel_position // 2) % half_point + else: + # Odd steps: use second half (from end) + idx = len(active_channels) - 1 - ((self.channel_position // 2) % half_point) + + self.channel_position += 1 + return active_channels[idx] + + # Default to up pattern + return active_channels[self.channel_position % len(active_channels)] + + def _get_bounce_channel(self, active_channels: List[int]) -> int: + """Get channel for bounce pattern with proper bounce logic""" + if len(active_channels) == 1: + return active_channels[0] + + # Create bounce sequence: 0,1,2,3,2,1,0,1,2,3... + bounce_length = (len(active_channels) - 1) * 2 + pos = self.channel_position % bounce_length + + if pos < len(active_channels): + # Going up: 0,1,2,3 + channel_idx = pos + else: + # Going down: 2,1,0 (skip the last one to avoid duplicate) + channel_idx = len(active_channels) - 2 - (pos - len(active_channels)) + + self.channel_position += 1 + return active_channels[max(0, min(len(active_channels) - 1, channel_idx))] + + def update(self): + """Main update loop - called frequently for timing precision""" + if not self.is_playing: + self.check_note_offs() + self.volume_engine.update_pattern(0.016) # ~60fps + return + + current_time = time.time() + + # Check if it's time for the next step + if current_time >= self.next_step_time and self.current_pattern: + self.process_step() + self.advance_step() + + # Check for notes to turn off + self.check_note_offs() + + # Update volume patterns + self.volume_engine.update_pattern(0.016) + + def process_step(self): + """Process the current arpeggio step""" + if not self.current_pattern: + return + + # Get note from pattern + note = self.current_pattern[self.pattern_position] + + # Apply swing + swing_offset = 0 + if self.swing != 0 and self.current_step % 2 == 1: + swing_offset = self.step_duration * self.swing * 0.1 + + # Route note to appropriate channel using distribution pattern + target_channel = self._get_next_channel() + + if target_channel: + # Use static velocity (not modified by volume patterns) + static_velocity = self.velocity + + # Calculate note duration + note_duration = self.step_duration * self.gate + note_end_time = time.time() + note_duration + swing_offset + + # Send note on + self.output_manager.send_note_on(target_channel, note, static_velocity) + + # Schedule note off + self.active_notes[(target_channel, note)] = note_end_time + + # Emit signal for GUI + self.note_triggered.emit(target_channel, note, static_velocity, note_duration) + + def advance_step(self): + """Advance to next step in pattern""" + old_pattern_position = self.pattern_position + self.pattern_position = (self.pattern_position + 1) % self.pattern_length + self.current_step += 1 + + # Check if pattern completed a full loop + if old_pattern_position != 0 and self.pattern_position == 0: + self.pattern_loops_completed += 1 + self.apply_armed_changes() + + # Calculate next step time with swing + base_time = self.next_step_time + self.step_duration + + # Apply swing to next step if it's an off-beat + if self.swing != 0 and (self.current_step + 1) % 2 == 1: + swing_offset = self.step_duration * self.swing * 0.1 + self.next_step_time = base_time + swing_offset + else: + self.next_step_time = base_time + + self.pattern_step.emit(self.current_step) + + def apply_armed_changes(self): + """Apply armed changes at pattern end""" + changes_applied = False + + # Apply armed root note + if self.armed_root_note is not None: + self.root_note = self.armed_root_note + self.armed_root_note = None + changes_applied = True + + # Apply armed scale + if self.armed_scale is not None: + self.scale = self.armed_scale + self.armed_scale = None + changes_applied = True + + # Apply armed pattern type + if self.armed_pattern_type is not None: + self.pattern_type = self.armed_pattern_type + self.armed_pattern_type = None + changes_applied = True + + # Apply armed channel distribution + if self.armed_channel_distribution is not None: + self.channel_distribution = self.armed_channel_distribution + self.channel_position = 0 # Reset position + self.armed_channel_distribution = None + changes_applied = True + + # If any changes were applied, regenerate pattern and emit signal + if changes_applied: + self.regenerate_pattern() + self.armed_state_changed.emit() + + def check_note_offs(self): + """Check for notes that should be turned off""" + current_time = time.time() + notes_to_remove = [] + + for (channel, note), end_time in self.active_notes.items(): + if current_time >= end_time: + self.output_manager.send_note_off(channel, note) + self.channel_manager.release_voice(channel, note) + notes_to_remove.append((channel, note)) + + for key in notes_to_remove: + del self.active_notes[key] + + def get_current_state(self) -> Dict: + """Get current arpeggiator state""" + return { + 'is_playing': self.is_playing, + 'root_note': self.root_note, + 'scale': self.scale, + 'pattern_type': self.pattern_type, + 'octave_range': self.octave_range, + 'note_speed': self.note_speed, + 'gate': self.gate, + 'swing': self.swing, + 'velocity': self.velocity, + 'tempo': self.tempo, + 'current_step': self.current_step, + 'pattern_position': self.pattern_position, + 'pattern_length': self.pattern_length, + 'held_notes': list(self.held_notes), + 'current_pattern': self.current_pattern.copy() + } \ No newline at end of file diff --git a/core/midi_channel_manager.py b/core/midi_channel_manager.py new file mode 100644 index 0000000..549a945 --- /dev/null +++ b/core/midi_channel_manager.py @@ -0,0 +1,183 @@ +""" +MIDI Channel Manager Module + +Handles MIDI channel management, instrument assignments, and voice allocation. +Manages up to 16 synth channels with individual program changes and polyphonic voice tracking. +""" + +import mido +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +class MIDIChannelManager(QObject): + """ + Manages MIDI channels, instruments, and voice allocation for multiple synths. + Each synth corresponds to a MIDI channel (1-16). + """ + + # Signals for GUI updates + active_synth_count_changed = pyqtSignal(int) + channel_instrument_changed = pyqtSignal(int, int) # channel, program + voice_allocation_changed = pyqtSignal(int, list) # channel, active_notes + + # General MIDI Program Names (first 128) + GM_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", + "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" + ] + + def __init__(self): + super().__init__() + self.active_synth_count = 8 # Default to 8 synths + self.max_synths = 16 + self.max_voices_per_synth = 3 + + # Channel instruments (1-based channel numbering) + self.channel_instruments: Dict[int, int] = {i: 0 for i in range(1, 17)} # Default to Piano + + # Voice allocation tracking + self.active_voices: Dict[int, List[int]] = {i: [] for i in range(1, 17)} # {channel: [note1, note2, note3]} + + def set_active_synth_count(self, count: int) -> bool: + """Set the number of active synths (1-16)""" + if 1 <= count <= self.max_synths: + self.active_synth_count = count + self.active_synth_count_changed.emit(count) + return True + return False + + def get_active_channels(self) -> List[int]: + """Get list of currently active channel numbers""" + return list(range(1, self.active_synth_count + 1)) + + def set_channel_instrument(self, channel: int, program: int) -> bool: + """Set instrument program for a specific channel""" + if 1 <= channel <= self.max_synths and 0 <= program <= 127: + self.channel_instruments[channel] = program + self.channel_instrument_changed.emit(channel, program) + return True + return False + + def set_all_instruments(self, program: int) -> bool: + """Set the same instrument for all active channels""" + if 0 <= program <= 127: + for channel in self.get_active_channels(): + self.channel_instruments[channel] = program + self.channel_instrument_changed.emit(channel, program) + return True + return False + + def get_channel_instrument(self, channel: int) -> Optional[int]: + """Get current instrument program for a channel""" + if 1 <= channel <= self.max_synths: + return self.channel_instruments.get(channel, 0) + return None + + def get_instrument_name(self, program: int) -> str: + """Get human-readable name for a GM program number""" + if 0 <= program < len(self.GM_PROGRAMS): + return self.GM_PROGRAMS[program] + return f"Program {program}" + + def allocate_voice(self, channel: int, note: int) -> bool: + """ + Allocate a voice for a note on a channel. + Returns True if voice was allocated, False if channel is full. + Implements voice stealing if necessary. + """ + if channel not in self.active_voices: + return False + + voices = self.active_voices[channel] + + # If note is already playing, don't allocate again + if note in voices: + return True + + # If we have space, just add it + if len(voices) < self.max_voices_per_synth: + voices.append(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + + # Voice stealing: remove the oldest note and add the new one + voices.pop(0) # Remove oldest + voices.append(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + + def release_voice(self, channel: int, note: int) -> bool: + """Release a voice for a note on a channel""" + if channel not in self.active_voices: + return False + + voices = self.active_voices[channel] + if note in voices: + voices.remove(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + return False + + def get_active_voices(self, channel: int) -> List[int]: + """Get list of currently active notes for a channel""" + return self.active_voices.get(channel, []).copy() + + def is_voice_available(self, channel: int) -> bool: + """Check if a channel has available voice slots""" + if channel not in self.active_voices: + return False + return len(self.active_voices[channel]) < self.max_voices_per_synth + + def get_voice_count(self, channel: int) -> int: + """Get current number of active voices for a channel""" + return len(self.active_voices.get(channel, [])) + + def clear_all_voices(self): + """Clear all active voices on all channels""" + for channel in range(1, self.max_synths + 1): + if self.active_voices[channel]: + self.active_voices[channel].clear() + self.voice_allocation_changed.emit(channel, []) + + def get_channel_status(self) -> Dict[int, Dict]: + """Get comprehensive status for all channels""" + status = {} + for channel in range(1, self.active_synth_count + 1): + status[channel] = { + 'instrument': self.channel_instruments[channel], + 'instrument_name': self.get_instrument_name(self.channel_instruments[channel]), + 'active_voices': self.active_voices[channel].copy(), + 'voice_count': len(self.active_voices[channel]), + 'voices_available': self.max_voices_per_synth - len(self.active_voices[channel]) + } + return status \ No newline at end of file diff --git a/core/output_manager.py b/core/output_manager.py new file mode 100644 index 0000000..3f90cc6 --- /dev/null +++ b/core/output_manager.py @@ -0,0 +1,336 @@ +""" +Output Manager Module + +Manages MIDI output routing between simulator mode and hardware mode. +Handles program changes, note on/off messages, and volume control. +""" + +import time +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +# Try to import MIDI libraries with fallbacks +try: + import mido + MIDO_AVAILABLE = True +except ImportError: + print("Warning: mido not available, creating fallback") + MIDO_AVAILABLE = False + +try: + import rtmidi + RTMIDI_AVAILABLE = True +except ImportError: + print("Warning: rtmidi not available") + RTMIDI_AVAILABLE = False + +# Fallback MIDI message class if mido not available +if not MIDO_AVAILABLE: + class Message: + def __init__(self, msg_type, **kwargs): + self.type = msg_type + self.__dict__.update(kwargs) + + def bytes(self): + if self.type == 'note_on': + return [0x90 | self.channel, self.note, self.velocity] + elif self.type == 'note_off': + return [0x80 | self.channel, self.note, self.velocity] + elif self.type == 'program_change': + return [0xC0 | self.channel, self.program] + elif self.type == 'control_change': + return [0xB0 | self.channel, self.control, self.value] + return [] + + # Create a mock mido module + class MockMido: + Message = Message + @staticmethod + def get_output_names(): + return ["No MIDI - Simulator Only"] + @staticmethod + def open_output(name): + return None + + mido = MockMido() + +class OutputManager(QObject): + """ + Manages MIDI output to either hardware devices or internal simulator. + Provides seamless switching between modes and handles all MIDI communication. + """ + + # Signals + mode_changed = pyqtSignal(str) # "simulator" or "hardware" + midi_device_changed = pyqtSignal(str) # device name + note_sent = pyqtSignal(int, int, int, bool) # channel, note, velocity, is_note_on + program_sent = pyqtSignal(int, int) # channel, program + volume_sent = pyqtSignal(int, int) # channel, volume + error_occurred = pyqtSignal(str) # error message + + def __init__(self, simulator_engine=None): + super().__init__() + + # Mode selection + self.current_mode = "simulator" # "simulator" or "hardware" + self.simulator_engine = simulator_engine + + # Hardware MIDI + self.midi_output = None + self.available_outputs = [] + self.selected_output = None + + # Channel volumes (CC7) + self.channel_volumes: Dict[int, int] = {i: 100 for i in range(1, 17)} + + # Program changes + self.channel_programs: Dict[int, int] = {i: 0 for i in range(1, 17)} + + # Initialize MIDI + self.refresh_midi_devices() + + def refresh_midi_devices(self): + """Refresh list of available MIDI output devices""" + try: + self.available_outputs = mido.get_output_names() + except Exception as e: + self.error_occurred.emit(f"Error refreshing MIDI devices: {str(e)}") + self.available_outputs = [] + + def get_available_outputs(self) -> List[str]: + """Get list of available MIDI output device names""" + return self.available_outputs.copy() + + def set_mode(self, mode: str) -> bool: + """Set output mode: 'simulator' or 'hardware'""" + if mode in ["simulator", "hardware"]: + old_mode = self.current_mode + self.current_mode = mode + + if mode == "hardware" and old_mode == "simulator": + # Switching to hardware - sync current state + self._sync_to_hardware() + elif mode == "simulator" and old_mode == "hardware": + # Switching to simulator - stop all hardware notes + self._all_notes_off_hardware() + + self.mode_changed.emit(mode) + return True + return False + + def set_midi_output(self, device_name: str) -> bool: + """Set hardware MIDI output device""" + if device_name not in self.available_outputs: + return False + + # Close existing connection + if self.midi_output: + try: + self.midi_output.close() + except: + pass + self.midi_output = None + + # Open new connection + try: + self.midi_output = mido.open_output(device_name) + self.selected_output = device_name + self.midi_device_changed.emit(device_name) + + # Sync current state to new device + if self.current_mode == "hardware": + self._sync_to_hardware() + + return True + except Exception as e: + self.error_occurred.emit(f"Error opening MIDI device {device_name}: {str(e)}") + return False + + def send_note_on(self, channel: int, note: int, velocity: int): + """Send note on message""" + if not (1 <= channel <= 16 and 0 <= note <= 127 and 0 <= velocity <= 127): + return + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.play_note(channel, note, velocity) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('note_on', channel=channel-1, note=note, velocity=velocity) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending note on: {str(e)}") + + # Always update simulator for visual feedback, regardless of mode + if self.simulator_engine: + self.simulator_engine.update_lighting(channel, velocity, velocity / 127.0) + + self.note_sent.emit(channel, note, velocity, True) + + def send_note_off(self, channel: int, note: int): + """Send note off message""" + if not (1 <= channel <= 16 and 0 <= note <= 127): + return + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.stop_note(channel, note) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('note_off', channel=channel-1, note=note, velocity=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending note off: {str(e)}") + + # Always update simulator for visual feedback, regardless of mode + if self.simulator_engine: + self.simulator_engine.fade_lighting(channel) + + self.note_sent.emit(channel, note, 0, False) + + def send_program_change(self, channel: int, program: int): + """Send program change message""" + if not (1 <= channel <= 16 and 0 <= program <= 127): + return + + self.channel_programs[channel] = program + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.change_program(channel, program) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('program_change', channel=channel-1, program=program) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending program change: {str(e)}") + + self.program_sent.emit(channel, program) + + def send_volume_change(self, channel: int, volume: int): + """Send channel volume change (CC7)""" + if not (1 <= channel <= 16 and 0 <= volume <= 127): + return + + self.channel_volumes[channel] = volume + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.set_channel_volume(channel, volume) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('control_change', channel=channel-1, control=7, value=volume) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending volume change: {str(e)}") + + self.volume_sent.emit(channel, volume) + + def send_all_notes_off(self, channel: int = None): + """Send all notes off message""" + channels = [channel] if channel else range(1, 17) + + for ch in channels: + if not (1 <= ch <= 16): + continue + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.all_notes_off(ch) + elif self.current_mode == "hardware" and self.midi_output: + try: + # Send CC 123 (All Notes Off) + msg = mido.Message('control_change', channel=ch-1, control=123, value=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending all notes off: {str(e)}") + + def send_panic(self): + """Send panic message to all channels""" + if self.current_mode == "hardware" and self.midi_output: + try: + for channel in range(16): + # All Sound Off (CC 120) + msg1 = mido.Message('control_change', channel=channel, control=120, value=0) + self.midi_output.send(msg1) + + # All Notes Off (CC 123) + msg2 = mido.Message('control_change', channel=channel, control=123, value=0) + self.midi_output.send(msg2) + + # Reset All Controllers (CC 121) + msg3 = mido.Message('control_change', channel=channel, control=121, value=0) + self.midi_output.send(msg3) + except Exception as e: + self.error_occurred.emit(f"Error sending panic: {str(e)}") + elif self.simulator_engine: + self.simulator_engine.panic() + + def _sync_to_hardware(self): + """Sync current program and volume settings to hardware""" + if not (self.current_mode == "hardware" and self.midi_output): + return + + try: + # Send current program changes + for channel, program in self.channel_programs.items(): + if 1 <= channel <= 16: + msg = mido.Message('program_change', channel=channel-1, program=program) + self.midi_output.send(msg) + time.sleep(0.001) # Small delay between messages + + # Send current volume settings + for channel, volume in self.channel_volumes.items(): + if 1 <= channel <= 16: + msg = mido.Message('control_change', channel=channel-1, control=7, value=volume) + self.midi_output.send(msg) + time.sleep(0.001) + + except Exception as e: + self.error_occurred.emit(f"Error syncing to hardware: {str(e)}") + + def _all_notes_off_hardware(self): + """Send all notes off to hardware when switching away""" + if self.midi_output: + try: + for channel in range(16): + msg = mido.Message('control_change', channel=channel, control=123, value=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error turning off hardware notes: {str(e)}") + + def get_channel_volume(self, channel: int) -> int: + """Get current volume for a channel""" + return self.channel_volumes.get(channel, 100) + + def get_channel_program(self, channel: int) -> int: + """Get current program for a channel""" + return self.channel_programs.get(channel, 0) + + def is_connected(self) -> bool: + """Check if output is properly connected""" + if self.current_mode == "simulator": + return self.simulator_engine is not None + elif self.current_mode == "hardware": + return self.midi_output is not None + return False + + def get_status_info(self) -> Dict: + """Get comprehensive status information""" + return { + 'mode': self.current_mode, + 'connected': self.is_connected(), + 'selected_output': self.selected_output, + 'available_outputs': self.available_outputs.copy(), + 'channel_volumes': self.channel_volumes.copy(), + 'channel_programs': self.channel_programs.copy() + } + + def close(self): + """Close MIDI connection""" + if self.midi_output: + try: + self._all_notes_off_hardware() + self.midi_output.close() + except: + pass + self.midi_output = None + + def __del__(self): + """Cleanup on destruction""" + self.close() \ No newline at end of file diff --git a/core/synth_router.py b/core/synth_router.py new file mode 100644 index 0000000..ff69bb5 --- /dev/null +++ b/core/synth_router.py @@ -0,0 +1,352 @@ +""" +Synth Router Module + +Routes MIDI notes to appropriate synth channels based on arpeggio patterns. +Handles spatial routing patterns for lighting effects and musical distribution. +""" + +import math +import random +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +from .midi_channel_manager import MIDIChannelManager + +class SynthRouter(QObject): + """ + Routes notes to synth channels based on musical and spatial patterns. + Integrates with MIDIChannelManager for voice allocation and channel management. + """ + + # Signals + note_routed = pyqtSignal(int, int, int) # channel, note, velocity + pattern_changed = pyqtSignal(str) + + # Routing pattern types + ROUTING_PATTERNS = [ + # Musical patterns + "single_synth", "round_robin", "chord_spread", "random_musical", + + # Spatial/lighting patterns + "bounce", "cycle", "wave", "ripple", "cascade", + "random_spatial", "spotlight", "alternating", "center_out", + "spiral", "zigzag" + ] + + def __init__(self, channel_manager: MIDIChannelManager): + super().__init__() + self.channel_manager = channel_manager + + # Current routing settings + self.current_pattern = "single_synth" + self.primary_channel = 1 # For single synth mode + + # Pattern state tracking + self.pattern_position = 0 + self.pattern_direction = 1 # 1 for forward, -1 for reverse + self.last_channels = [] # Track recent channel usage + + # Bounce pattern specific + self.bounce_position = 0 + self.bounce_direction = 1 + + # Cycle pattern specific + self.cycle_position = 0 + + # Random state + self.random_weights = {} # Channel preference weights + + def set_routing_pattern(self, pattern_name: str) -> bool: + """Set the current routing pattern""" + if pattern_name in self.ROUTING_PATTERNS: + self.current_pattern = pattern_name + self.reset_pattern_state() + self.pattern_changed.emit(pattern_name) + return True + return False + + def set_primary_channel(self, channel: int): + """Set primary channel for single synth mode""" + if 1 <= channel <= 16: + self.primary_channel = channel + + def reset_pattern_state(self): + """Reset pattern-specific state variables""" + self.pattern_position = 0 + self.pattern_direction = 1 + self.bounce_position = 0 + self.bounce_direction = 1 + self.cycle_position = 0 + self.last_channels.clear() + + # Initialize random weights + active_channels = self.channel_manager.get_active_channels() + for channel in active_channels: + self.random_weights[channel] = 1.0 + + def route_note(self, note: int, velocity: int, chord_notes: List[int] = None) -> Optional[int]: + """ + Route a note to the appropriate synth channel based on current pattern. + Returns the selected channel number, or None if routing failed. + """ + active_channels = self.channel_manager.get_active_channels() + if not active_channels: + return None + + # Handle chord spreading for specific patterns + if chord_notes and len(chord_notes) > 1 and self.current_pattern == "chord_spread": + return self._route_chord_spread(note, velocity, chord_notes) + + # Route single note based on pattern + target_channel = self._select_target_channel(note, active_channels) + + if target_channel: + # Check voice availability and allocate + if self.channel_manager.allocate_voice(target_channel, note): + self.note_routed.emit(target_channel, note, velocity) + self._update_pattern_state(target_channel, active_channels) + return target_channel + + return None + + def _select_target_channel(self, note: int, active_channels: List[int]) -> Optional[int]: + """Select target channel based on current routing pattern""" + + if self.current_pattern == "single_synth": + return self.primary_channel if self.primary_channel in active_channels else active_channels[0] + + elif self.current_pattern == "round_robin": + return self._round_robin_select(active_channels) + + elif self.current_pattern == "random_musical": + return random.choice(active_channels) + + elif self.current_pattern == "bounce": + return self._bounce_select(active_channels) + + elif self.current_pattern == "cycle": + return self._cycle_select(active_channels) + + elif self.current_pattern == "wave": + return self._wave_select(active_channels) + + elif self.current_pattern == "ripple": + return self._ripple_select(active_channels) + + elif self.current_pattern == "cascade": + return self._cascade_select(active_channels) + + elif self.current_pattern == "random_spatial": + return self._weighted_random_select(active_channels) + + elif self.current_pattern == "spotlight": + return self._spotlight_select(active_channels) + + elif self.current_pattern == "alternating": + return self._alternating_select(active_channels) + + elif self.current_pattern == "center_out": + return self._center_out_select(active_channels) + + elif self.current_pattern == "spiral": + return self._spiral_select(active_channels) + + elif self.current_pattern == "zigzag": + return self._zigzag_select(active_channels) + + # Default fallback + return active_channels[0] + + def _round_robin_select(self, active_channels: List[int]) -> int: + """Simple round-robin through channels""" + channel = active_channels[self.pattern_position % len(active_channels)] + return channel + + def _bounce_select(self, active_channels: List[int]) -> int: + """Bounce back and forth between first and last channels""" + if len(active_channels) == 1: + return active_channels[0] + + # Calculate bounce position + if self.bounce_direction == 1: + if self.bounce_position >= len(active_channels) - 1: + self.bounce_direction = -1 + else: + if self.bounce_position <= 0: + self.bounce_direction = 1 + + return active_channels[self.bounce_position] + + def _cycle_select(self, active_channels: List[int]) -> int: + """Cycle through channels in order""" + channel = active_channels[self.cycle_position % len(active_channels)] + return channel + + def _wave_select(self, active_channels: List[int]) -> int: + """Wave pattern across channels""" + wave_position = math.sin(self.pattern_position * 0.5) * 0.5 + 0.5 + channel_index = int(wave_position * (len(active_channels) - 1)) + return active_channels[channel_index] + + def _ripple_select(self, active_channels: List[int]) -> int: + """Ripple effect from center outward""" + center = len(active_channels) // 2 + ripple_radius = (self.pattern_position // 2) % (len(active_channels) // 2 + 1) + + # Select channels at current ripple radius + candidates = [] + for i, channel in enumerate(active_channels): + distance = abs(i - center) + if distance == ripple_radius: + candidates.append(channel) + + return random.choice(candidates) if candidates else active_channels[center] + + def _cascade_select(self, active_channels: List[int]) -> int: + """Cascade effect - sequential with overlap""" + cascade_width = 3 # Number of channels in cascade + cascade_position = self.pattern_position % (len(active_channels) + cascade_width) + + # Find channels in current cascade window + candidates = [] + for i in range(cascade_width): + idx = (cascade_position - i) % len(active_channels) + if idx >= 0: + candidates.append(active_channels[idx]) + + return random.choice(candidates) if candidates else active_channels[0] + + def _weighted_random_select(self, active_channels: List[int]) -> int: + """Random selection with dynamic weights""" + # Adjust weights to avoid recently used channels + for channel in self.last_channels[-3:]: # Last 3 channels get lower weight + if channel in self.random_weights: + self.random_weights[channel] *= 0.5 + + # Normalize weights + total_weight = sum(self.random_weights.get(ch, 1.0) for ch in active_channels) + if total_weight <= 0: + return random.choice(active_channels) + + # Weighted random selection + rand_val = random.random() * total_weight + cumulative = 0 + for channel in active_channels: + cumulative += self.random_weights.get(channel, 1.0) + if rand_val <= cumulative: + return channel + + return active_channels[-1] # Fallback + + def _spotlight_select(self, active_channels: List[int]) -> int: + """Spotlight effect - focus on one channel at a time""" + spotlight_duration = 8 # Notes per spotlight + spotlight_channel_idx = (self.pattern_position // spotlight_duration) % len(active_channels) + return active_channels[spotlight_channel_idx] + + def _alternating_select(self, active_channels: List[int]) -> int: + """Alternate between even and odd channels""" + if len(active_channels) < 2: + return active_channels[0] + + if self.pattern_position % 2 == 0: + # Even positions - select from first half + half = len(active_channels) // 2 + return active_channels[self.pattern_position // 2 % (half if half > 0 else 1)] + else: + # Odd positions - select from second half + half = len(active_channels) // 2 + second_half = active_channels[half:] if half > 0 else active_channels + return second_half[(self.pattern_position // 2) % len(second_half)] + + def _center_out_select(self, active_channels: List[int]) -> int: + """Select from center outward""" + center = len(active_channels) // 2 + radius = (self.pattern_position // 2) % (len(active_channels) // 2 + 1) + + # Alternate between left and right of center + if self.pattern_position % 2 == 0: + idx = center + radius + else: + idx = center - radius + + idx = max(0, min(len(active_channels) - 1, idx)) + return active_channels[idx] + + def _spiral_select(self, active_channels: List[int]) -> int: + """Spiral pattern through channels""" + # Create spiral by varying step size + spiral_step = 2 if len(active_channels) > 4 else 1 + idx = (self.pattern_position * spiral_step) % len(active_channels) + return active_channels[idx] + + def _zigzag_select(self, active_channels: List[int]) -> int: + """Zigzag pattern through channels""" + period = len(active_channels) * 2 - 2 + position = self.pattern_position % period + + if position < len(active_channels): + idx = position + else: + idx = len(active_channels) - 2 - (position - len(active_channels)) + + idx = max(0, min(len(active_channels) - 1, idx)) + return active_channels[idx] + + def _route_chord_spread(self, note: int, velocity: int, chord_notes: List[int]) -> Optional[int]: + """Spread chord notes across different channels""" + active_channels = self.channel_manager.get_active_channels() + + # Find position of current note in chord + try: + note_index = chord_notes.index(note) + except ValueError: + note_index = 0 + + # Distribute chord notes across channels + if len(chord_notes) <= len(active_channels): + # Enough channels for each note + target_channel = active_channels[note_index % len(active_channels)] + else: + # More notes than channels - use round robin + target_channel = active_channels[note_index % len(active_channels)] + + return target_channel + + def _update_pattern_state(self, selected_channel: int, active_channels: List[int]): + """Update pattern state after routing a note""" + self.pattern_position += 1 + + # Track recent channels + self.last_channels.append(selected_channel) + if len(self.last_channels) > 5: + self.last_channels.pop(0) + + # Update bounce position + if self.current_pattern == "bounce": + self.bounce_position += self.bounce_direction + if self.bounce_position >= len(active_channels) - 1: + self.bounce_direction = -1 + elif self.bounce_position <= 0: + self.bounce_direction = 1 + + # Update cycle position + elif self.current_pattern == "cycle": + self.cycle_position += 1 + + # Restore random weights gradually + for channel in active_channels: + if channel in self.random_weights: + self.random_weights[channel] = min(1.0, self.random_weights[channel] + 0.1) + + def get_pattern_info(self) -> Dict: + """Get current pattern state information""" + return { + 'pattern': self.current_pattern, + 'position': self.pattern_position, + 'primary_channel': self.primary_channel, + 'bounce_position': self.bounce_position, + 'bounce_direction': self.bounce_direction, + 'cycle_position': self.cycle_position, + 'recent_channels': self.last_channels.copy() + } \ No newline at end of file diff --git a/core/volume_pattern_engine.py b/core/volume_pattern_engine.py new file mode 100644 index 0000000..caf9708 --- /dev/null +++ b/core/volume_pattern_engine.py @@ -0,0 +1,280 @@ +""" +Volume Pattern Engine Module + +Handles volume and velocity pattern generation for visual lighting effects. +Creates dynamic volume patterns that control both audio levels and lighting brightness. +""" + +import math +import random +from typing import Dict, List, Tuple, Optional +from PyQt5.QtCore import QObject, pyqtSignal + +class VolumePatternEngine(QObject): + """ + Generates volume and velocity patterns for enhanced visual effects. + Controls both MIDI channel volume (CC7) and note velocity for brightness control. + """ + + # Signals for GUI updates + pattern_changed = pyqtSignal(str) # pattern_name + volume_updated = pyqtSignal(int, float) # channel, volume (0.0-1.0) + + # Pattern types available + PATTERN_TYPES = [ + "static", "swell", "breathing", "wave", "build", "fade", + "pulse", "alternating", "stutter", "cascade", "ripple", + "random_sparkle", "spotlight", "bounce_volume" + ] + + def __init__(self): + super().__init__() + + # Current pattern settings + self.current_pattern = "static" + self.pattern_speed = 1.0 # Speed multiplier + self.pattern_intensity = 1.0 # Intensity multiplier + + # Position tracking for patterns + self.pattern_position = 0.0 + self.pattern_direction = 1 # 1 for forward, -1 for reverse + + # Volume ranges per channel {channel: (min, max)} + self.channel_volume_ranges: Dict[int, Tuple[float, float]] = {} + self.velocity_ranges: Dict[int, Tuple[int, int]] = {} + + # Global ranges (applied to all channels if no individual range set) + self.global_volume_range = (0.2, 1.0) # 20% to 100% + self.global_velocity_range = (40, 127) # MIDI velocity range + + # Pattern state + self.pattern_phases: Dict[int, float] = {} # Per-channel phase offsets + self.random_states: Dict[int, float] = {} # For random patterns + + # Initialize random states for channels + for channel in range(1, 17): + self.pattern_phases[channel] = random.random() * 2 * math.pi + self.random_states[channel] = random.random() + + def set_pattern(self, pattern_name: str) -> bool: + """Set the current volume pattern""" + if pattern_name in self.PATTERN_TYPES: + self.current_pattern = pattern_name + self.pattern_changed.emit(pattern_name) + return True + return False + + def set_pattern_speed(self, speed: float): + """Set pattern speed multiplier (0.1 to 5.0)""" + self.pattern_speed = max(0.1, min(5.0, speed)) + + def set_pattern_intensity(self, intensity: float): + """Set pattern intensity multiplier (0.0 to 2.0)""" + self.pattern_intensity = max(0.0, min(2.0, intensity)) + + def set_channel_volume_range(self, channel: int, min_vol: float, max_vol: float): + """Set volume range for a specific channel (0.0 to 1.0)""" + min_vol = max(0.0, min(1.0, min_vol)) + max_vol = max(min_vol, min(1.0, max_vol)) + self.channel_volume_ranges[channel] = (min_vol, max_vol) + + def set_velocity_range(self, channel: int, min_vel: int, max_vel: int): + """Set velocity range for a specific channel (0-127)""" + min_vel = max(0, min(127, min_vel)) + max_vel = max(min_vel, min(127, max_vel)) + self.velocity_ranges[channel] = (min_vel, max_vel) + + def set_global_ranges(self, min_vol: float, max_vol: float, min_vel: int, max_vel: int): + """Set global volume and velocity ranges""" + self.global_volume_range = (max(0.0, min(1.0, min_vol)), max(0.0, min(1.0, max_vol))) + self.global_velocity_range = (max(0, min(127, min_vel)), max(0, min(127, max_vel))) + + def get_channel_volume_range(self, channel: int) -> Tuple[float, float]: + """Get volume range for a channel (uses global if not set individually)""" + return self.channel_volume_ranges.get(channel, self.global_volume_range) + + def get_velocity_range(self, channel: int) -> Tuple[int, int]: + """Get velocity range for a channel (uses global if not set individually)""" + return self.velocity_ranges.get(channel, self.global_velocity_range) + + def update_pattern(self, delta_time: float): + """Update pattern position based on elapsed time""" + self.pattern_position += delta_time * self.pattern_speed + + def get_channel_volume(self, channel: int, active_channel_count: int = 8) -> float: + """ + Calculate current volume for a channel based on active pattern. + Returns volume as float 0.0 to 1.0 + """ + min_vol, max_vol = self.get_channel_volume_range(channel) + + # Get base pattern value (0.0 to 1.0) + pattern_value = self._calculate_pattern_value(channel, active_channel_count) + + # Apply intensity + pattern_value = pattern_value * self.pattern_intensity + pattern_value = max(0.0, min(1.0, pattern_value)) + + # Scale to channel's volume range + volume = min_vol + (max_vol - min_vol) * pattern_value + + return volume + + def get_note_velocity(self, base_velocity: int, channel: int, active_channel_count: int = 8) -> int: + """ + Calculate note velocity based on volume pattern and base velocity. + Returns MIDI velocity (0-127) + """ + min_vel, max_vel = self.get_velocity_range(channel) + + # Get pattern influence (0.0 to 1.0) + pattern_value = self._calculate_pattern_value(channel, active_channel_count) + pattern_value = pattern_value * self.pattern_intensity + pattern_value = max(0.0, min(1.0, pattern_value)) + + # Blend base velocity with pattern + pattern_velocity = min_vel + (max_vel - min_vel) * pattern_value + + # Combine with base velocity (weighted average) + final_velocity = int((base_velocity + pattern_velocity) / 2) + + return max(0, min(127, final_velocity)) + + def _calculate_pattern_value(self, channel: int, active_channel_count: int) -> float: + """Calculate raw pattern value (0.0 to 1.0) for a channel""" + + if self.current_pattern == "static": + return 1.0 + + elif self.current_pattern == "swell": + # Gradual swell up and down + cycle = math.sin(self.pattern_position * 0.5) * 0.5 + 0.5 + return cycle + + elif self.current_pattern == "breathing": + # Smooth breathing rhythm + cycle = math.sin(self.pattern_position) * 0.5 + 0.5 + return 0.3 + cycle * 0.7 # Keep minimum at 30% + + elif self.current_pattern == "wave": + # Sine wave across channels + phase_offset = (channel - 1) * (2 * math.pi / active_channel_count) + wave = math.sin(self.pattern_position + phase_offset) * 0.5 + 0.5 + return wave + + elif self.current_pattern == "build": + # Gradual crescendo + build_progress = (self.pattern_position * 0.1) % 2.0 + if build_progress > 1.0: + build_progress = 2.0 - build_progress # Fade back down + return build_progress + + elif self.current_pattern == "fade": + # Gradual diminuendo + fade_progress = 1.0 - ((self.pattern_position * 0.1) % 1.0) + return fade_progress + + elif self.current_pattern == "pulse": + # Sharp rhythmic pulses + pulse = math.sin(self.pattern_position * 2) + return 1.0 if pulse > 0.8 else 0.3 + + elif self.current_pattern == "alternating": + # Alternate between high and low + return 1.0 if int(self.pattern_position) % 2 == 0 else 0.3 + + elif self.current_pattern == "stutter": + # Rapid volume changes + stutter = math.sin(self.pattern_position * 8) * 0.5 + 0.5 + return stutter + + elif self.current_pattern == "cascade": + # Volume cascades across channels + return self._cascade_pattern(channel, active_channel_count) + + elif self.current_pattern == "ripple": + # Ripple effect from center + return self._ripple_pattern(channel, active_channel_count) + + elif self.current_pattern == "random_sparkle": + # Random sparkle effect + return self._random_sparkle_pattern(channel) + + elif self.current_pattern == "spotlight": + # Spotlight effect - one channel bright, others dim + return self._spotlight_pattern(channel, active_channel_count) + + elif self.current_pattern == "bounce_volume": + # Volume follows bounce pattern + return self._bounce_volume_pattern(channel, active_channel_count) + + return 1.0 # Default fallback + + def _cascade_pattern(self, channel: int, active_channel_count: int) -> float: + """Volume cascade across channels""" + cascade_position = (self.pattern_position * 0.5) % active_channel_count + distance = min( + abs(channel - 1 - cascade_position), + active_channel_count - abs(channel - 1 - cascade_position) + ) + return max(0.2, 1.0 - (distance / active_channel_count) * 0.8) + + def _ripple_pattern(self, channel: int, active_channel_count: int) -> float: + """Ripple effect from center""" + center = active_channel_count / 2 + distance = abs(channel - 1 - center) + ripple_phase = self.pattern_position - distance * 0.5 + ripple = math.sin(ripple_phase) * 0.5 + 0.5 + return max(0.2, ripple) + + def _random_sparkle_pattern(self, channel: int) -> float: + """Random sparkle effect""" + # Update random state periodically + if int(self.pattern_position * 4) % 8 == 0: + self.random_states[channel] = random.random() + + base_random = self.random_states[channel] + sparkle_threshold = 0.7 + + if base_random > sparkle_threshold: + # Sparkle! Add some randomness to timing + sparkle_intensity = (base_random - sparkle_threshold) / (1.0 - sparkle_threshold) + return 0.3 + sparkle_intensity * 0.7 + else: + return 0.2 + base_random * 0.3 + + def _spotlight_pattern(self, channel: int, active_channel_count: int) -> float: + """Spotlight effect - one channel bright, others dim""" + spotlight_channel = int(self.pattern_position * 0.3) % active_channel_count + 1 + if channel == spotlight_channel: + return 1.0 + else: + return 0.2 + + def _bounce_volume_pattern(self, channel: int, active_channel_count: int) -> float: + """Volume follows bounce pattern between first and last channels""" + bounce_position = (self.pattern_position * 0.8) % (2 * (active_channel_count - 1)) + + if bounce_position <= active_channel_count - 1: + target_channel = bounce_position + 1 + else: + target_channel = 2 * active_channel_count - bounce_position - 1 + + distance = abs(channel - target_channel) + return max(0.3, 1.0 - distance * 0.3) + + def get_all_channel_volumes(self, active_channel_count: int) -> Dict[int, float]: + """Get volume values for all active channels""" + volumes = {} + for channel in range(1, active_channel_count + 1): + volume = self.get_channel_volume(channel, active_channel_count) + volumes[channel] = volume + self.volume_updated.emit(channel, volume) + return volumes + + def reset_pattern(self): + """Reset pattern to beginning""" + self.pattern_position = 0.0 + for channel in range(1, 17): + self.pattern_phases[channel] = random.random() * 2 * math.pi + self.random_states[channel] = random.random() \ No newline at end of file diff --git a/diagnose_audio_midi.py b/diagnose_audio_midi.py new file mode 100644 index 0000000..bf0d6a5 --- /dev/null +++ b/diagnose_audio_midi.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Audio and MIDI Diagnostic Tool +Helps identify issues with audio playback and MIDI output +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_pygame_audio(): + """Test pygame audio system directly""" + print("=== Testing Pygame Audio ===") + + try: + import pygame + import numpy as np + + # Initialize pygame mixer + pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=512) + pygame.mixer.init() + print("PASS: Pygame mixer initialized successfully") + + # Test basic audio playback + print("Testing basic audio generation...") + + # Generate a simple sine wave (440 Hz for 1 second) + sample_rate = 22050 + duration = 1.0 # seconds + frequency = 440 # A4 + + # Generate samples + samples = np.sin(2 * np.pi * frequency * np.linspace(0, duration, int(sample_rate * duration))) + + # Apply envelope and scale + envelope = np.exp(-np.linspace(0, 3, len(samples))) # Fade out + samples = samples * envelope * 0.5 + + # Convert to 16-bit integers and make stereo + samples = (samples * 32767).astype(np.int16) + stereo_samples = np.column_stack((samples, samples)) + + # Create and play sound + sound = pygame.sndarray.make_sound(stereo_samples) + print("Playing test tone (440Hz for 1 second)...") + sound.play() + + # Wait for playback + time.sleep(1.2) + + print("✓ Direct pygame audio test completed") + return True + + except Exception as e: + print(f"✗ Pygame audio test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_simulator_audio(): + """Test the simulator audio system""" + print("\n=== Testing Simulator Audio ===") + + try: + from simulator.simulator_engine import SimulatorEngine + + simulator = SimulatorEngine() + print(f"✓ Simulator initialized, audio enabled: {simulator.audio_enabled}") + print(f" Audio initialized: {simulator.audio_initialized_flag}") + print(f" Stereo mode: {getattr(simulator, 'stereo_mode', 'Unknown')}") + + # Test note playback + print("Testing note playback through simulator...") + simulator.play_note(1, 60, 80) # Channel 1, Middle C, velocity 80 + time.sleep(0.8) + simulator.stop_note(1, 60) + + print("✓ Simulator audio test completed") + return True + + except Exception as e: + print(f"✗ Simulator audio test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_midi_devices(): + """Test MIDI device detection""" + print("\n=== Testing MIDI Devices ===") + + try: + import mido + print("✓ Mido library available") + + # List MIDI outputs + outputs = mido.get_output_names() + print(f"Available MIDI outputs ({len(outputs)}):") + for i, output in enumerate(outputs): + print(f" {i+1}. {output}") + + if outputs: + print(f"\nTesting MIDI output with: {outputs[0]}") + try: + midi_out = mido.open_output(outputs[0]) + print("✓ MIDI output opened successfully") + + # Send test note + print("Sending test note (Middle C)...") + msg_on = mido.Message('note_on', channel=0, note=60, velocity=80) + msg_off = mido.Message('note_off', channel=0, note=60, velocity=0) + + midi_out.send(msg_on) + print(" - Note ON sent") + time.sleep(0.5) + midi_out.send(msg_off) + print(" - Note OFF sent") + + midi_out.close() + print("✓ MIDI test completed") + return True + + except Exception as e: + print(f"✗ MIDI output test failed: {e}") + return False + else: + print("⚠ No MIDI outputs available") + return False + + except ImportError: + print("✗ Mido library not available") + return False + except Exception as e: + print(f"✗ MIDI test failed: {e}") + return False + +def test_arpeggiator_integration(): + """Test the full arpeggiator system""" + print("\n=== Testing Arpeggiator Integration ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create components + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + print("✓ All components initialized") + + # Test simulator mode + output_manager.set_mode("simulator") + print(f"✓ Output mode: {output_manager.current_mode}") + + # Configure arpeggiator + arpeggiator.set_root_note(60) # Middle C + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + + # Add notes to trigger arpeggiator + print("Adding notes and starting arpeggiator...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + # Start arpeggiator + started = arpeggiator.start() + print(f"✓ Arpeggiator started: {started}") + + if started: + print("Letting arpeggiator run for 3 seconds...") + time.sleep(3) + arpeggiator.stop() + print("✓ Arpeggiator stopped") + + return True + + except Exception as e: + print(f"✗ Arpeggiator integration test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_system_audio(): + """Test system audio capabilities""" + print("\n=== Testing System Audio ===") + + try: + import pygame + + # Get mixer info + pygame.mixer.init() + print(f"✓ Pygame version: {pygame.version.ver}") + + # Check mixer settings + freq = pygame.mixer.get_init() + if freq: + print(f"✓ Mixer frequency: {freq[0]}Hz, {freq[1]}-bit, {freq[2]} channels") + else: + print("✗ Mixer not initialized") + + # Check number of channels + channels = pygame.mixer.get_num_channels() + print(f"✓ Available sound channels: {channels}") + + return True + + except Exception as e: + print(f"✗ System audio test failed: {e}") + return False + +def main(): + """Run all diagnostic tests""" + print("MIDI Arpeggiator - Audio & MIDI Diagnostics") + print("=" * 50) + + results = {} + + # Run tests + results['system_audio'] = test_system_audio() + results['pygame_audio'] = test_pygame_audio() + results['simulator_audio'] = test_simulator_audio() + results['midi_devices'] = test_midi_devices() + results['arpeggiator'] = test_arpeggiator_integration() + + # Summary + print("\n" + "=" * 50) + print("DIAGNOSTIC SUMMARY:") + print("=" * 50) + + for test_name, result in results.items(): + status = "✓ PASS" if result else "✗ FAIL" + print(f"{test_name:20}: {status}") + + # Recommendations + print("\nRECOMMENDATIONS:") + print("-" * 30) + + if not results['pygame_audio']: + print("• Audio issues detected - check Windows audio settings") + print("• Try different audio drivers or devices") + print("• Check if other applications are using audio exclusively") + + if not results['midi_devices']: + print("• No MIDI devices found for hardware mode") + print("• Install virtual MIDI cables (like loopMIDI) for software synths") + print("• Check MIDI device drivers") + + if not results['simulator_audio']: + print("• Simulator audio not working - check pygame audio initialization") + + if not results['arpeggiator']: + print("• Arpeggiator integration issues - check component initialization") + + print(f"\nOverall success rate: {sum(results.values())}/{len(results)} tests passed") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fallback/__init__.py b/fallback/__init__.py new file mode 100644 index 0000000..978c29b --- /dev/null +++ b/fallback/__init__.py @@ -0,0 +1 @@ +# Fallback MIDI implementations \ No newline at end of file diff --git a/fallback/rtmidi_fallback.py b/fallback/rtmidi_fallback.py new file mode 100644 index 0000000..c4136aa --- /dev/null +++ b/fallback/rtmidi_fallback.py @@ -0,0 +1,92 @@ +""" +Fallback RTMIDI implementation using pygame.midi for Windows compatibility. +""" + +import pygame.midi +import time +from typing import List, Optional, Callable + +class MidiOut: + def __init__(self, device_id): + pygame.midi.init() + self.device_id = device_id + self.midi_out = pygame.midi.Output(device_id) + + def send_message(self, message): + """Send MIDI message""" + if hasattr(message, 'bytes'): + # mido message + data = message.bytes() + else: + # Raw bytes + data = message + + if len(data) == 3: + self.midi_out.write_short(data[0], data[1], data[2]) + elif len(data) == 2: + self.midi_out.write_short(data[0], data[1]) + + def close(self): + if hasattr(self, 'midi_out'): + self.midi_out.close() + +class MidiIn: + def __init__(self, device_id, callback=None): + pygame.midi.init() + self.device_id = device_id + self.midi_in = pygame.midi.Input(device_id) + self.callback = callback + + def set_callback(self, callback): + self.callback = callback + + def poll(self): + """Poll for MIDI input (call this regularly)""" + if self.midi_in.poll() and self.callback: + midi_events = self.midi_in.read(10) + for event in midi_events: + # Convert pygame midi event to mido-like message + if self.callback: + self.callback(event) + + def close(self): + if hasattr(self, 'midi_in'): + self.midi_in.close() + +def get_output_names() -> List[str]: + """Get available MIDI output device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3]: # is_output + devices.append(info[1].decode()) + return devices + +def get_input_names() -> List[str]: + """Get available MIDI input device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2]: # is_input + devices.append(info[1].decode()) + return devices + +def open_output(name: str) -> MidiOut: + """Open MIDI output by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3] and info[1].decode() == name: # is_output and name matches + return MidiOut(i) + raise ValueError(f"MIDI output '{name}' not found") + +def open_input(name: str, callback=None) -> MidiIn: + """Open MIDI input by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2] and info[1].decode() == name: # is_input and name matches + return MidiIn(i, callback) + raise ValueError(f"MIDI input '{name}' not found") diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..6b04a7a --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ +# GUI module - PyQt5 user interface components \ No newline at end of file diff --git a/gui/__pycache__/__init__.cpython-310.pyc b/gui/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..8260f97 Binary files /dev/null and b/gui/__pycache__/__init__.cpython-310.pyc differ diff --git a/gui/__pycache__/arpeggiator_controls.cpython-310.pyc b/gui/__pycache__/arpeggiator_controls.cpython-310.pyc new file mode 100644 index 0000000..5c07be7 Binary files /dev/null and b/gui/__pycache__/arpeggiator_controls.cpython-310.pyc differ diff --git a/gui/__pycache__/channel_controls.cpython-310.pyc b/gui/__pycache__/channel_controls.cpython-310.pyc new file mode 100644 index 0000000..40c7e88 Binary files /dev/null and b/gui/__pycache__/channel_controls.cpython-310.pyc differ diff --git a/gui/__pycache__/main_window.cpython-310.pyc b/gui/__pycache__/main_window.cpython-310.pyc new file mode 100644 index 0000000..86b0083 Binary files /dev/null and b/gui/__pycache__/main_window.cpython-310.pyc differ diff --git a/gui/__pycache__/output_controls.cpython-310.pyc b/gui/__pycache__/output_controls.cpython-310.pyc new file mode 100644 index 0000000..893444d Binary files /dev/null and b/gui/__pycache__/output_controls.cpython-310.pyc differ diff --git a/gui/__pycache__/preset_controls.cpython-310.pyc b/gui/__pycache__/preset_controls.cpython-310.pyc new file mode 100644 index 0000000..1497be3 Binary files /dev/null and b/gui/__pycache__/preset_controls.cpython-310.pyc differ diff --git a/gui/__pycache__/simulator_display.cpython-310.pyc b/gui/__pycache__/simulator_display.cpython-310.pyc new file mode 100644 index 0000000..61859df Binary files /dev/null and b/gui/__pycache__/simulator_display.cpython-310.pyc differ diff --git a/gui/__pycache__/volume_controls.cpython-310.pyc b/gui/__pycache__/volume_controls.cpython-310.pyc new file mode 100644 index 0000000..324d208 Binary files /dev/null and b/gui/__pycache__/volume_controls.cpython-310.pyc differ diff --git a/gui/arpeggiator_controls.py b/gui/arpeggiator_controls.py new file mode 100644 index 0000000..da736f2 --- /dev/null +++ b/gui/arpeggiator_controls.py @@ -0,0 +1,601 @@ +""" +Arpeggiator Controls - READABLE BUTTONS WITH PROPER SIZING +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Readable arpeggiator controls with properly sized buttons""" + + def __init__(self, arpeggiator, channel_manager, simulator=None): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.simulator = simulator + + # State tracking + self.presets = {} + self.current_preset = None + self.root_note_buttons = {} + self.octave_buttons = {} + self.scale_buttons = {} + self.pattern_buttons = {} + self.distribution_buttons = {} + self.speed_buttons = {} + + self.current_root_note = 0 + self.current_octave = 4 + self.current_scale = "major" + self.current_pattern = "up" + self.current_distribution = "up" + self.current_speed = "1/8" + + # Armed state tracking + self.armed_root_note_button = None + self.armed_octave_button = None + self.armed_scale_button = None + self.armed_pattern_button = None + self.armed_distribution_button = None + # Speed changes apply immediately - no armed state needed + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Clean quadrant layout with readable buttons""" + # Main grid + main = QGridLayout(self) + main.setSpacing(8) + main.setContentsMargins(8, 8, 8, 8) + + # Equal quadrants + main.addWidget(self.basic_quadrant(), 0, 0) + main.addWidget(self.distribution_quadrant(), 0, 1) + main.addWidget(self.pattern_quadrant(), 1, 0) + main.addWidget(self.timing_quadrant(), 1, 1) + + main.setRowStretch(0, 1) + main.setRowStretch(1, 1) + main.setColumnStretch(0, 1) + main.setColumnStretch(1, 1) + + def basic_quadrant(self): + """Basic settings with readable buttons""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + # Root notes - 12 buttons in horizontal row, NO spacing between buttons + layout.addWidget(QLabel("Root Note:")) + notes_widget = QWidget() + notes_layout = QHBoxLayout(notes_widget) + notes_layout.setSpacing(0) # NO spacing between buttons + notes_layout.setContentsMargins(0, 0, 0, 0) + + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + for i, note in enumerate(notes): + btn = QPushButton(note) + btn.setFixedSize(40, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, n=i: self.on_root_note_clicked(n)) + + if i == 0: + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.root_note_buttons[i] = btn + notes_layout.addWidget(btn) + + layout.addWidget(notes_widget) + + # Octaves - 6 buttons in horizontal row, NO spacing between buttons + layout.addWidget(QLabel("Octave:")) + octave_widget = QWidget() + octave_layout = QHBoxLayout(octave_widget) + octave_layout.setSpacing(0) # NO spacing between buttons + octave_layout.setContentsMargins(0, 0, 0, 0) + + for octave in range(3, 9): # C3 to C8 + btn = QPushButton(f"C{octave}") + btn.setFixedSize(50, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, o=octave: self.on_octave_clicked(o)) + + if octave == 4: + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.octave_buttons[octave] = btn + octave_layout.addWidget(btn) + + layout.addWidget(octave_widget) + + # Scales - 2 rows of 4, minimal vertical spacing + layout.addWidget(QLabel("Scale:")) + scales_widget = QWidget() + scales_layout = QGridLayout(scales_widget) + scales_layout.setSpacing(0) # NO horizontal spacing + scales_layout.setVerticalSpacing(2) # Minimal vertical spacing + scales_layout.setContentsMargins(0, 0, 0, 0) + + main_scales = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "pentatonic_major", "pentatonic_minor"] + for i, scale in enumerate(main_scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, s=scale: self.on_scale_clicked(s)) + + if scale == "major": + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.scale_buttons[scale] = btn + scales_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(scales_widget) + + # Octave range dropdown + layout.addWidget(QLabel("Octave Range:")) + self.octave_range_combo = QComboBox() + self.octave_range_combo.setFixedHeight(20) + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + layout.addWidget(self.octave_range_combo) + + return group + + def distribution_quadrant(self): + """Distribution with readable buttons and simulator display""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + layout.addWidget(QLabel("Distribution Pattern:")) + + # 2 rows of 4 distribution buttons + dist_widget = QWidget() + dist_layout = QGridLayout(dist_widget) + dist_layout.setSpacing(0) # NO horizontal spacing + dist_layout.setVerticalSpacing(2) # Minimal vertical spacing + dist_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "bounce", "random", "cycle", "alternating", "single_channel"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, p=pattern: self.on_distribution_clicked(p)) + + if pattern == "up": + btn.setChecked(True) + btn.setStyleSheet("background: #0066cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #0088ee;") + + self.distribution_buttons[pattern] = btn + dist_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(dist_widget) + + # Description + self.dist_desc = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.dist_desc.setStyleSheet("font-size: 10px; color: gray;") + layout.addWidget(self.dist_desc) + + # Simulator display + if self.simulator: + from .simulator_display import SimulatorDisplay + self.simulator_display = SimulatorDisplay(self.simulator, self.channel_manager) + layout.addWidget(self.simulator_display) + else: + # Create placeholder for now + placeholder = QLabel("Simulator display will appear here") + placeholder.setStyleSheet("font-size: 10px; color: gray; text-align: center;") + placeholder.setAlignment(Qt.AlignCenter) + layout.addWidget(placeholder) + + return group + + def pattern_quadrant(self): + """Pattern with readable buttons""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + layout.addWidget(QLabel("Arpeggio Pattern:")) + + # 2 rows of 4 pattern buttons + pattern_widget = QWidget() + pattern_layout = QGridLayout(pattern_widget) + pattern_layout.setSpacing(0) # NO horizontal spacing + pattern_layout.setVerticalSpacing(2) # Minimal vertical spacing + pattern_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "down_up", "random", "chord", "note_order", "custom"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, p=pattern: self.on_pattern_clicked(p)) + + if pattern == "up": + btn.setChecked(True) + btn.setStyleSheet("background: #cc6600; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ee8800;") + + self.pattern_buttons[pattern] = btn + pattern_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(pattern_widget) + + return group + + def timing_quadrant(self): + """Timing with readable controls""" + group = QGroupBox("Timing Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + # Tempo + tempo_layout = QHBoxLayout() + tempo_layout.addWidget(QLabel("Tempo:")) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + self.tempo_spin.setFixedHeight(20) + tempo_layout.addWidget(self.tempo_spin) + layout.addLayout(tempo_layout) + + # Speed buttons + layout.addWidget(QLabel("Note Speed:")) + speed_widget = QWidget() + speed_layout = QHBoxLayout(speed_widget) + speed_layout.setSpacing(0) # NO spacing between buttons + speed_layout.setContentsMargins(0, 0, 0, 0) + + self.speed_buttons = {} + speeds = ["1/32", "1/16", "1/8", "1/4", "1/2", "1/1"] + for speed in speeds: + btn = QPushButton(speed) + btn.setFixedSize(50, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, s=speed: self.on_speed_clicked(s)) + + if speed == "1/8": + btn.setChecked(True) + btn.setStyleSheet("background: #9933cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + + self.speed_buttons[speed] = btn + speed_layout.addWidget(btn) + + layout.addWidget(speed_widget) + + # Gate + gate_layout = QHBoxLayout() + gate_layout.addWidget(QLabel("Gate:")) + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) + self.gate_slider.setValue(100) + self.gate_slider.setFixedHeight(20) + gate_layout.addWidget(self.gate_slider) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(40) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout) + + # Swing + swing_layout = QHBoxLayout() + swing_layout.addWidget(QLabel("Swing:")) + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + self.swing_slider.setFixedHeight(20) + swing_layout.addWidget(self.swing_slider) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(40) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout) + + # Velocity + velocity_layout = QHBoxLayout() + velocity_layout.addWidget(QLabel("Velocity:")) + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + self.velocity_slider.setFixedHeight(20) + velocity_layout.addWidget(self.velocity_slider) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(40) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout) + + # Presets + preset_layout = QHBoxLayout() + self.save_btn = QPushButton("Save Preset") + self.save_btn.setFixedSize(80, 20) + self.load_btn = QPushButton("Load Preset") + self.load_btn.setFixedSize(80, 20) + preset_layout.addWidget(self.save_btn) + preset_layout.addWidget(self.load_btn) + preset_layout.addStretch() + layout.addLayout(preset_layout) + + return group + + def connect_signals(self): + """Connect all signals""" + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + # Speed is now handled by individual button click handlers + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + self.save_btn.clicked.connect(self.save_preset) + self.load_btn.clicked.connect(self.load_preset) + + if hasattr(self.arpeggiator, 'armed_state_changed'): + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + # Event handlers + def on_root_note_clicked(self, note_index): + midi_note = self.current_octave * 12 + note_index + + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange, waits for pattern end + if self.armed_root_note_button: + self.armed_root_note_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_root_note_button = self.root_note_buttons[note_index] + self.root_note_buttons[note_index].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_root_note'): + self.arpeggiator.arm_root_note(midi_note) + else: + # IMMEDIATE CHANGE - apply right away + if self.current_root_note in self.root_note_buttons: + self.root_note_buttons[self.current_root_note].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_root_note = note_index + self.root_note_buttons[note_index].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_root_note'): + self.arpeggiator.set_root_note(midi_note) + + def on_octave_clicked(self, octave): + midi_note = octave * 12 + self.current_root_note + + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_octave_button: + self.armed_octave_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_octave_button = self.octave_buttons[octave] + self.octave_buttons[octave].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_root_note'): + self.arpeggiator.arm_root_note(midi_note) + else: + # IMMEDIATE CHANGE + if self.current_octave in self.octave_buttons: + self.octave_buttons[self.current_octave].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_octave = octave + self.octave_buttons[octave].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_root_note'): + self.arpeggiator.set_root_note(midi_note) + + def on_scale_clicked(self, scale): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_scale_button: + self.armed_scale_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_scale_button = self.scale_buttons[scale] + self.scale_buttons[scale].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_scale'): + self.arpeggiator.arm_scale(scale) + else: + # IMMEDIATE CHANGE + if self.current_scale in self.scale_buttons: + self.scale_buttons[self.current_scale].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_scale = scale + self.scale_buttons[scale].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_scale'): + self.arpeggiator.set_scale(scale) + + def on_pattern_clicked(self, pattern): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_pattern_button: + self.armed_pattern_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_pattern_button = self.pattern_buttons[pattern] + self.pattern_buttons[pattern].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_pattern_type'): + self.arpeggiator.arm_pattern_type(pattern) + else: + # IMMEDIATE CHANGE + if self.current_pattern in self.pattern_buttons: + self.pattern_buttons[self.current_pattern].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_pattern = pattern + self.pattern_buttons[pattern].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_pattern_type'): + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_clicked(self, pattern): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_distribution_button: + self.armed_distribution_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_distribution_button = self.distribution_buttons[pattern] + self.distribution_buttons[pattern].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_channel_distribution'): + self.arpeggiator.arm_channel_distribution(pattern) + else: + # IMMEDIATE CHANGE + if self.current_distribution in self.distribution_buttons: + self.distribution_buttons[self.current_distribution].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_distribution = pattern + self.distribution_buttons[pattern].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_channel_distribution'): + self.arpeggiator.set_channel_distribution(pattern) + + def on_speed_clicked(self, speed): + # Speed changes apply immediately (no armed state needed for timing) + if self.current_speed in self.speed_buttons: + self.speed_buttons[self.current_speed].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.current_speed = speed + self.speed_buttons[speed].setStyleSheet("background: #9933cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + + if hasattr(self.arpeggiator, 'set_note_speed'): + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + if hasattr(self.arpeggiator, 'set_tempo'): + self.arpeggiator.set_tempo(float(tempo)) + + # on_speed_changed removed - now using on_speed_clicked with buttons + + @pyqtSlot(int) + def on_gate_changed(self, value): + self.gate_label.setText(f"{value}%") + if hasattr(self.arpeggiator, 'set_gate'): + self.arpeggiator.set_gate(value / 100.0) + + @pyqtSlot(int) + def on_swing_changed(self, value): + self.swing_label.setText(f"{value}%") + if hasattr(self.arpeggiator, 'set_swing'): + self.arpeggiator.set_swing(value / 100.0) + + @pyqtSlot(int) + def on_velocity_changed(self, value): + self.velocity_label.setText(str(value)) + if hasattr(self.arpeggiator, 'set_velocity'): + self.arpeggiator.set_velocity(value) + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + if hasattr(self.arpeggiator, 'set_octave_range'): + self.arpeggiator.set_octave_range(index + 1) + + def save_preset(self): + preset_name = f"Preset_{len(self.presets) + 1}" + self.presets[preset_name] = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution + } + print(f"Saved {preset_name}") + + def load_preset(self): + if not self.presets: + print("No presets saved") + return + preset_name = list(self.presets.keys())[0] + preset = self.presets[preset_name] + # Apply preset logic here + print(f"Loaded {preset_name}") + + @pyqtSlot() + def update_armed_states(self): + """Handle armed state updates - orange buttons become green at pattern end""" + # Check if armed states were applied (armed values become None when applied) + + # Root note armed -> active + if self.armed_root_note_button and hasattr(self.arpeggiator, 'armed_root_note') and self.arpeggiator.armed_root_note is None: + # Find which note this was + for note_index, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + # Clear old active + if self.current_root_note in self.root_note_buttons: + self.root_note_buttons[self.current_root_note].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + # Set new active (orange -> green) + self.current_root_note = note_index + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_root_note_button = None + break + + # Octave armed -> active + if self.armed_octave_button and hasattr(self.arpeggiator, 'armed_root_note') and self.arpeggiator.armed_root_note is None: + for octave, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + if self.current_octave in self.octave_buttons: + self.octave_buttons[self.current_octave].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_octave = octave + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_octave_button = None + break + + # Scale armed -> active + if self.armed_scale_button and hasattr(self.arpeggiator, 'armed_scale') and self.arpeggiator.armed_scale is None: + for scale, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + if self.current_scale in self.scale_buttons: + self.scale_buttons[self.current_scale].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_scale = scale + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_scale_button = None + break + + # Pattern armed -> active + if self.armed_pattern_button and hasattr(self.arpeggiator, 'armed_pattern_type') and self.arpeggiator.armed_pattern_type is None: + for pattern, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + if self.current_pattern in self.pattern_buttons: + self.pattern_buttons[self.current_pattern].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_pattern = pattern + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_pattern_button = None + break + + # Distribution armed -> active + if self.armed_distribution_button and hasattr(self.arpeggiator, 'armed_channel_distribution') and self.arpeggiator.armed_channel_distribution is None: + for distribution, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + if self.current_distribution in self.distribution_buttons: + self.distribution_buttons[self.current_distribution].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_distribution = distribution + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_distribution_button = None + break + + # Speed changes apply immediately - no armed state needed \ No newline at end of file diff --git a/gui/arpeggiator_controls_backup.py b/gui/arpeggiator_controls_backup.py new file mode 100644 index 0000000..474345d --- /dev/null +++ b/gui/arpeggiator_controls_backup.py @@ -0,0 +1,964 @@ +""" +Arpeggiator Controls GUI + +Control panel for arpeggiator settings including patterns, scales, timing, etc. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QCheckBox, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Control panel for arpeggiator parameters""" + + def __init__(self, arpeggiator, channel_manager): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + + # Preset system + self.presets = {} + self.current_preset = None + self.preset_rotation_enabled = False + self.preset_rotation_interval = 4 # patterns + self.preset_rotation_timer = None + self.pattern_count_since_preset_change = 0 + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface with quadrant layout""" + layout = QGridLayout(self) + layout.setSpacing(5) # Reduced from 15 to 5 + layout.setContentsMargins(5, 5, 5, 5) # Minimal margins + + # Make columns equal width and rows equal height + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 1) + layout.setRowStretch(0, 1) + layout.setRowStretch(1, 1) + + # Top-left: Basic Settings + basic_group = self.create_basic_settings() + layout.addWidget(basic_group, 0, 0) + + # Top-right: Channel Distribution + distribution_group = self.create_distribution_settings() + layout.addWidget(distribution_group, 0, 1) + + # Bottom-left: Pattern Settings + pattern_group = self.create_pattern_settings() + layout.addWidget(pattern_group, 1, 0) + + # Bottom-right: Timing Settings + timing_group = self.create_timing_settings() + layout.addWidget(timing_group, 1, 1) + + # Add preset rotation controls + self.setup_preset_rotation() + + def create_basic_settings(self) -> QGroupBox: + """Create basic arpeggiator settings - no scrollbars, all visible""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # 12 Note buttons - all visible in a compact grid + note_frame = QFrame() + note_layout = QVBoxLayout(note_frame) + note_layout.setSpacing(3) + note_layout.addWidget(QLabel("Root Note:")) + note_widget = self.create_note_buttons() + note_layout.addWidget(note_widget) + layout.addWidget(note_frame) + + # 12 Octave select buttons - all visible in a row + octave_frame = QFrame() + octave_layout = QVBoxLayout(octave_frame) + octave_layout.setSpacing(3) + octave_layout.addWidget(QLabel("Octave:")) + octave_widget = self.create_octave_buttons() + octave_layout.addWidget(octave_widget) + layout.addWidget(octave_frame) + + # Scale buttons - compact grid, all visible + scale_frame = QFrame() + scale_layout = QVBoxLayout(scale_frame) + scale_layout.setSpacing(3) + scale_layout.addWidget(QLabel("Scale:")) + scale_widget = self.create_scale_buttons() + scale_layout.addWidget(scale_widget) + layout.addWidget(scale_frame) + + # Octave Range dropdown + octave_frame = QFrame() + octave_layout = QVBoxLayout(octave_frame) + octave_layout.setSpacing(3) + octave_layout.addWidget(QLabel("Octave Range:")) + self.octave_range_combo = QComboBox() + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + self.octave_range_combo.setCurrentIndex(0) # 1 octave + octave_layout.addWidget(self.octave_range_combo) + layout.addWidget(octave_frame) + + return group + + def create_note_buttons(self) -> QWidget: + """Create 12 note selection buttons in compact layout""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + # Note names for one octave + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + self.root_note_buttons = {} + self.current_root_note = 0 # C (index in notes array) + self.armed_root_note_button = None + + # Create 12 note buttons in 2 rows of 6 + for i, note in enumerate(notes): + button = QPushButton(note) + button.setCheckable(True) + button.setFixedSize(40, 20) + button.clicked.connect(lambda checked, n=i: self.on_root_note_button_clicked(n)) + + # Set initial state + if i == 0: # C + button.setChecked(True) + self.update_root_note_button_style(button, "active") + else: + self.update_root_note_button_style(button, "inactive") + + self.root_note_buttons[i] = button + layout.addWidget(button, i // 6, i % 6) + + return widget + + def create_octave_buttons(self) -> QWidget: + """Create 12 octave select buttons (C0 to C11)""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.octave_buttons = {} + self.current_octave = 4 # C4 + self.armed_octave_button = None + self.armed_root_note_button = None + + # Create octave buttons C0 to C11 (12 octaves) + for octave in range(12): + button = QPushButton(f"C{octave}") + button.setCheckable(True) + button.setFixedSize(40, 20) + button.clicked.connect(lambda checked, o=octave: self.on_octave_button_clicked(o)) + + # Set initial state + if octave == 4: # C4 + button.setChecked(True) + self.update_octave_button_style(button, "active") + else: + self.update_octave_button_style(button, "inactive") + + self.octave_buttons[octave] = button + layout.addWidget(button, octave // 6, octave % 6) + + return widget + + def create_scale_buttons(self) -> QWidget: + """Create scale selection buttons - compact layout""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.scale_buttons = {} + self.current_scale = "major" + self.armed_scale_button = None + + scales = list(self.arpeggiator.SCALES.keys()) + + # Create scales in a compact grid + for i, scale in enumerate(scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(70, 20) + button.clicked.connect(lambda checked, s=scale: self.on_scale_button_clicked(s)) + + # Set initial state + if scale == "major": + button.setChecked(True) + self.update_scale_button_style(button, "active") + else: + self.update_scale_button_style(button, "inactive") + + self.scale_buttons[scale] = button + layout.addWidget(button, i // 4, i % 4) # 4 buttons per row + + return widget + + def update_root_note_button_style(self, button, state): + """Update button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def update_octave_button_style(self, button, state): + """Update octave button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def update_scale_button_style(self, button, state): + """Update scale button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_pattern_buttons(self) -> QWidget: + """Create pattern selection buttons - compact grid""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.pattern_buttons = {} + self.current_pattern = "up" + self.armed_pattern_button = None + + patterns = self.arpeggiator.PATTERN_TYPES + + # Create patterns in a compact grid + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(70, 20) + button.clicked.connect(lambda checked, p=pattern: self.on_pattern_button_clicked(p)) + + # Set initial state + if pattern == "up": + button.setChecked(True) + self.update_pattern_button_style(button, "active") + else: + self.update_pattern_button_style(button, "inactive") + + self.pattern_buttons[pattern] = button + layout.addWidget(button, i // 3, i % 3) # 3 buttons per row + + return widget + + def update_pattern_button_style(self, button, state): + """Update pattern button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_distribution_buttons(self) -> QWidget: + """Create distribution selection buttons - compact grid""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.distribution_buttons = {} + self.current_distribution = "up" + self.armed_distribution_button = None + + patterns = self.arpeggiator.CHANNEL_DISTRIBUTION_PATTERNS + + # Create distributions in a compact grid + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(80, 20) + button.clicked.connect(lambda checked, p=pattern: self.on_distribution_button_clicked(p)) + + # Set initial state + if pattern == "up": + button.setChecked(True) + self.update_distribution_button_style(button, "active") + else: + self.update_distribution_button_style(button, "inactive") + + self.distribution_buttons[pattern] = button + layout.addWidget(button, i // 3, i % 3) # 3 buttons per row + + return widget + + def update_distribution_button_style(self, button, state): + """Update distribution button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_pattern_settings(self) -> QGroupBox: + """Create pattern settings controls - no scrollbars""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # Pattern Type Buttons - all visible + pattern_frame = QFrame() + pattern_layout = QVBoxLayout(pattern_frame) + pattern_layout.addWidget(QLabel("Pattern:")) + pattern_widget = self.create_pattern_buttons() + pattern_layout.addWidget(pattern_widget) + layout.addWidget(pattern_frame) + + return group + + def create_distribution_settings(self) -> QGroupBox: + """Create channel distribution settings - no scrollbars""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # Channel Distribution Pattern Buttons - all visible + dist_frame = QFrame() + dist_layout = QVBoxLayout(dist_frame) + dist_layout.addWidget(QLabel("Distribution:")) + distribution_widget = self.create_distribution_buttons() + dist_layout.addWidget(distribution_widget) + layout.addWidget(dist_frame) + + # Description label + self.distribution_description = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.distribution_description.setStyleSheet("color: #888888; font-style: italic;") + self.distribution_description.setWordWrap(True) + layout.addWidget(self.distribution_description) + + return group + + def create_timing_settings(self) -> QGroupBox: + """Create timing controls""" + group = QGroupBox("Timing Settings") + layout = QGridLayout(group) + + # Tempo + layout.addWidget(QLabel("Tempo:"), 0, 0) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + layout.addWidget(self.tempo_spin, 0, 1) + + # Note Speed + layout.addWidget(QLabel("Note Speed:"), 1, 0) + self.speed_combo = QComboBox() + speeds = list(self.arpeggiator.NOTE_SPEEDS.keys()) + for speed in speeds: + self.speed_combo.addItem(speed) + self.speed_combo.setCurrentText("1/8") + layout.addWidget(self.speed_combo, 1, 1) + + # Gate (Note Length) + layout.addWidget(QLabel("Gate:"), 2, 0) + gate_layout = QHBoxLayout() + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) # 10% to 200% + self.gate_slider.setValue(100) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(40) + gate_layout.addWidget(self.gate_slider) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout, 2, 1) + + # Swing + layout.addWidget(QLabel("Swing:"), 3, 0) + swing_layout = QHBoxLayout() + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(40) + swing_layout.addWidget(self.swing_slider) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout, 3, 1) + + # Base Velocity + layout.addWidget(QLabel("Velocity:"), 4, 0) + velocity_layout = QHBoxLayout() + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(40) + velocity_layout.addWidget(self.velocity_slider) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout, 4, 1) + + return group + + def setup_preset_rotation(self): + """Setup preset rotation controls""" + # Find the timing settings group that was just created + timing_groups = self.findChildren(QGroupBox) + timing_group = None + for group in timing_groups: + if group.title() == "Timing Settings": + timing_group = group + break + + if timing_group and hasattr(timing_group, 'layout') and timing_group.layout(): + layout = timing_group.layout() + + # Preset rotation controls + layout.addWidget(QLabel("Preset Rotation:"), 6, 0) + + preset_layout = QVBoxLayout() + + # Enable checkbox + self.preset_rotation_checkbox = QPushButton("Enable Presets") + self.preset_rotation_checkbox.setCheckable(True) + self.preset_rotation_checkbox.setFixedSize(100, 25) + preset_layout.addWidget(self.preset_rotation_checkbox) + + # Interval control + interval_layout = QHBoxLayout() + interval_layout.addWidget(QLabel("Every:")) + self.preset_interval_spin = QSpinBox() + self.preset_interval_spin.setRange(1, 16) + self.preset_interval_spin.setValue(4) + self.preset_interval_spin.setSuffix(" loops") + self.preset_interval_spin.setFixedSize(80, 25) + interval_layout.addWidget(self.preset_interval_spin) + preset_layout.addLayout(interval_layout) + + # Preset buttons + preset_button_layout = QHBoxLayout() + self.save_preset_button = QPushButton("Save") + self.save_preset_button.setFixedSize(50, 25) + preset_button_layout.addWidget(self.save_preset_button) + + self.load_preset_button = QPushButton("Load") + self.load_preset_button.setFixedSize(50, 25) + preset_button_layout.addWidget(self.load_preset_button) + preset_layout.addLayout(preset_button_layout) + + layout.addLayout(preset_layout, 6, 1) + + def connect_signals(self): + """Connect GUI controls to arpeggiator""" + # Basic settings + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + + # Connect new button signals + for note_index, button in self.root_note_buttons.items(): + button.clicked.connect(lambda checked, n=note_index: self.on_root_note_button_clicked(n)) + + for octave, button in self.octave_buttons.items(): + button.clicked.connect(lambda checked, o=octave: self.on_octave_button_clicked(o)) + + # Timing settings + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + self.speed_combo.currentTextChanged.connect(self.on_speed_changed) + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + + # Arpeggiator state changes + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + + def on_root_note_button_clicked(self, note_index): + """Handle root note button click - uses note index (0-11) and octave""" + midi_note = self.current_octave * 12 + note_index + + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_root_note_button: + self.update_root_note_button_style(self.armed_root_note_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.root_note_buttons[note_index] + self.armed_root_note_button = button + self.update_root_note_button_style(button, "armed") + self.arpeggiator.arm_root_note(midi_note) + else: + # Apply immediately + self.set_active_root_note(note_index) + self.arpeggiator.set_root_note(midi_note) + + def on_scale_button_clicked(self, scale): + """Handle scale button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_scale_button: + self.update_scale_button_style(self.armed_scale_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.scale_buttons[scale] + self.armed_scale_button = button + self.update_scale_button_style(button, "armed") + self.arpeggiator.arm_scale(scale) + else: + # Apply immediately + self.set_active_scale(scale) + self.arpeggiator.set_scale(scale) + + def on_pattern_button_clicked(self, pattern): + """Handle pattern button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_pattern_button: + self.update_pattern_button_style(self.armed_pattern_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.pattern_buttons[pattern] + self.armed_pattern_button = button + self.update_pattern_button_style(button, "armed") + self.arpeggiator.arm_pattern_type(pattern) + else: + # Apply immediately + self.set_active_pattern(pattern) + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_button_clicked(self, distribution): + """Handle distribution button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_distribution_button: + self.update_distribution_button_style(self.armed_distribution_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.distribution_buttons[distribution] + self.armed_distribution_button = button + self.update_distribution_button_style(button, "armed") + self.arpeggiator.arm_channel_distribution(distribution) + else: + # Apply immediately + self.set_active_distribution(distribution) + self.arpeggiator.set_channel_distribution(distribution) + + def on_octave_button_clicked(self, octave): + """Handle octave button click""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_octave_button: + self.update_octave_button_style(self.armed_octave_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.octave_buttons[octave] + self.armed_octave_button = button + self.update_octave_button_style(button, "armed") + # Arm the new MIDI note + midi_note = octave * 12 + self.current_root_note + self.arpeggiator.arm_root_note(midi_note) + else: + # Apply immediately + self.set_active_octave(octave) + midi_note = octave * 12 + self.current_root_note + self.arpeggiator.set_root_note(midi_note) + + def set_active_root_note(self, note_index): + """Set active root note button""" + # Clear current active state + if self.current_root_note in self.root_note_buttons: + self.update_root_note_button_style(self.root_note_buttons[self.current_root_note], "inactive") + + # Clear armed state if it's the same note + if self.armed_root_note_button and self.armed_root_note_button == self.root_note_buttons.get(note_index): + self.armed_root_note_button = None + + # Set new active state + self.current_root_note = note_index + if note_index in self.root_note_buttons: + self.update_root_note_button_style(self.root_note_buttons[note_index], "active") + + def set_active_octave(self, octave): + """Set active octave button""" + # Clear current active state + if self.current_octave in self.octave_buttons: + self.update_octave_button_style(self.octave_buttons[self.current_octave], "inactive") + + # Clear armed state if it's the same octave + if self.armed_octave_button and self.armed_octave_button == self.octave_buttons.get(octave): + self.armed_octave_button = None + + # Set new active state + self.current_octave = octave + if octave in self.octave_buttons: + self.update_octave_button_style(self.octave_buttons[octave], "active") + + def set_active_scale(self, scale): + """Set active scale button - FIXED to clear previous active""" + # Clear current active state + if self.current_scale in self.scale_buttons: + self.update_scale_button_style(self.scale_buttons[self.current_scale], "inactive") + + # Clear armed state if it's the same scale + if self.armed_scale_button and self.armed_scale_button == self.scale_buttons.get(scale): + self.armed_scale_button = None + + # Set new active state + self.current_scale = scale + if scale in self.scale_buttons: + self.update_scale_button_style(self.scale_buttons[scale], "active") + + def set_active_pattern(self, pattern): + """Set active pattern button - FIXED to clear previous active""" + # Clear current active state + if self.current_pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + # Clear armed state if it's the same pattern + if self.armed_pattern_button and self.armed_pattern_button == self.pattern_buttons.get(pattern): + self.armed_pattern_button = None + + # Set new active state + self.current_pattern = pattern + if pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[pattern], "active") + + def set_active_distribution(self, distribution): + """Set active distribution button - FIXED to clear previous active""" + # Clear current active state + if self.current_distribution in self.distribution_buttons: + self.update_distribution_button_style(self.distribution_buttons[self.current_distribution], "inactive") + + # Clear armed state if it's the same distribution + if self.armed_distribution_button and self.armed_distribution_button == self.distribution_buttons.get(distribution): + self.armed_distribution_button = None + + # Set new active state + self.current_distribution = distribution + if distribution in self.distribution_buttons: + self.update_distribution_button_style(self.distribution_buttons[distribution], "active") + self.update_distribution_description(distribution) + + @pyqtSlot() + def update_armed_states(self): + """Update armed states when arpeggiator state changes - FIXED logic""" + # This is called when armed states are applied at pattern end + + if self.armed_root_note_button and self.arpeggiator.armed_root_note is None: + # Armed root note was applied, move to active + note_index = None + for n, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + note_index = n + break + if note_index is not None: + self.set_active_root_note(note_index) + + if self.armed_octave_button and self.arpeggiator.armed_root_note is None: + # Armed octave was applied, move to active + octave = None + for o, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + octave = o + break + if octave is not None: + self.set_active_octave(octave) + + if self.armed_scale_button and self.arpeggiator.armed_scale is None: + # Armed scale was applied, move to active + scale = None + for s, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + scale = s + break + if scale: + self.set_active_scale(scale) + + if self.armed_pattern_button and self.arpeggiator.armed_pattern_type is None: + # Armed pattern was applied, move to active + pattern = None + for p, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + pattern = p + break + if pattern: + self.set_active_pattern(pattern) + + if self.armed_distribution_button and self.arpeggiator.armed_channel_distribution is None: + # Armed distribution was applied, move to active + distribution = None + for d, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + distribution = d + break + if distribution: + self.set_active_distribution(distribution) + + def update_distribution_description(self, distribution: str): + """Update distribution pattern description""" + descriptions = { + "up": "Channels: 1 → 2 → 3 → 4 → 5 → 6...", + "down": "Channels: 6 → 5 → 4 → 3 → 2 → 1...", + "up_down": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "bounce": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "random": "Channels: Random selection each note", + "cycle": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 1 → 2...", + "alternating": "Channels: 1 → 6 → 2 → 5 → 3 → 4...", + "single_channel": "Channels: All notes on channel 1" + } + + description = descriptions.get(distribution, "Unknown pattern") + self.distribution_description.setText(description) + + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + """Handle tempo change""" + self.arpeggiator.set_tempo(float(tempo)) + + @pyqtSlot(str) + def on_speed_changed(self, speed): + """Handle note speed change""" + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_gate_changed(self, gate_percent): + """Handle gate change""" + gate_value = gate_percent / 100.0 + self.arpeggiator.set_gate(gate_value) + self.gate_label.setText(f"{gate_percent}%") + + @pyqtSlot(int) + def on_swing_changed(self, swing_percent): + """Handle swing change""" + swing_value = swing_percent / 100.0 + self.arpeggiator.set_swing(swing_value) + self.swing_label.setText(f"{swing_percent}%") + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + """Handle octave range change""" + octaves = index + 1 # Convert 0-based index to 1-4 range + self.arpeggiator.set_octave_range(octaves) + + @pyqtSlot(int) + def on_velocity_changed(self, velocity): + """Handle velocity change""" + self.arpeggiator.set_velocity(velocity) + self.velocity_label.setText(str(velocity)) + + @pyqtSlot(bool) + def on_preset_rotation_toggled(self, enabled): + """Handle preset rotation enable/disable""" + self.preset_rotation_enabled = enabled + print(f"Preset rotation {'enabled' if enabled else 'disabled'}") + + @pyqtSlot(int) + def on_preset_interval_changed(self, interval): + """Handle preset rotation interval change""" + self.preset_rotation_interval = interval + print(f"Preset interval set to {interval} patterns") + + def save_current_preset(self): + """Save current settings as a preset""" + preset_name = f"Preset_{len(self.presets) + 1}" + + preset = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution, + 'octave_range': self.octave_range_combo.currentIndex() if hasattr(self, 'octave_range_combo') else 0, + 'tempo': self.tempo_spin.value(), + 'speed': self.speed_combo.currentText(), + 'gate': self.gate_slider.value(), + 'swing': self.swing_slider.value(), + 'velocity': self.velocity_slider.value() + } + + self.presets[preset_name] = preset + print(f"Saved {preset_name}") + + def load_preset_dialog(self): + """Show preset selection dialog""" + if not self.presets: + print("No presets saved") + return + + # For now, cycle through presets + preset_names = list(self.presets.keys()) + if self.current_preset in preset_names: + current_index = preset_names.index(self.current_preset) + next_index = (current_index + 1) % len(preset_names) + else: + next_index = 0 + + next_preset = preset_names[next_index] + self.load_preset(next_preset) + + def load_preset(self, preset_name: str): + """Load a specific preset""" + if preset_name not in self.presets: + return + + preset = self.presets[preset_name] + self.current_preset = preset_name + + # Apply preset settings + if self.arpeggiator.is_playing: + # Arm changes for pattern-end application + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.arm_root_note(midi_note) + self.arpeggiator.arm_scale(preset['scale']) + self.arpeggiator.arm_pattern_type(preset['pattern']) + self.arpeggiator.arm_channel_distribution(preset['distribution']) + else: + # Apply immediately + self.set_active_root_note(preset['root_note']) + self.set_active_octave(preset['octave']) + self.set_active_scale(preset['scale']) + self.set_active_pattern(preset['pattern']) + self.set_active_distribution(preset['distribution']) + + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.set_root_note(midi_note) + self.arpeggiator.set_scale(preset['scale']) + self.arpeggiator.set_pattern_type(preset['pattern']) + self.arpeggiator.set_channel_distribution(preset['distribution']) + + # Apply other settings immediately + if hasattr(self, 'octave_range_combo'): + self.octave_range_combo.setCurrentIndex(preset['octave_range']) + self.tempo_spin.setValue(preset['tempo']) + self.speed_combo.setCurrentText(preset['speed']) + self.gate_slider.setValue(preset['gate']) + self.swing_slider.setValue(preset['swing']) + self.velocity_slider.setValue(preset['velocity']) + + print(f"Loaded {preset_name}") \ No newline at end of file diff --git a/gui/arpeggiator_controls_new.py b/gui/arpeggiator_controls_new.py new file mode 100644 index 0000000..c8a16b1 --- /dev/null +++ b/gui/arpeggiator_controls_new.py @@ -0,0 +1,691 @@ +""" +Arpeggiator Controls GUI - Complete Redesign + +Clean quadrant layout with no overlapping buttons, guaranteed spacing. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QCheckBox, QFrame, QScrollArea, QSizePolicy) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Control panel for arpeggiator parameters - redesigned for no overlaps""" + + def __init__(self, arpeggiator, channel_manager): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + + # Preset system + self.presets = {} + self.current_preset = None + self.preset_rotation_enabled = False + self.preset_rotation_interval = 4 + self.preset_rotation_timer = None + self.pattern_count_since_preset_change = 0 + + # Button tracking + self.root_note_buttons = {} + self.octave_buttons = {} + self.scale_buttons = {} + self.pattern_buttons = {} + self.distribution_buttons = {} + + # Current states + self.current_root_note = 0 # C + self.current_octave = 4 # C4 + self.current_scale = "major" + self.current_pattern = "up" + self.current_distribution = "up" + + # Armed states + self.armed_root_note_button = None + self.armed_octave_button = None + self.armed_scale_button = None + self.armed_pattern_button = None + self.armed_distribution_button = None + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up clean quadrant layout""" + # Main layout - fixed size quadrants + main_layout = QGridLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + + # Create four equal quadrants + basic_quad = self.create_basic_quadrant() + distribution_quad = self.create_distribution_quadrant() + pattern_quad = self.create_pattern_quadrant() + timing_quad = self.create_timing_quadrant() + + # Add to grid with equal sizing + main_layout.addWidget(basic_quad, 0, 0) + main_layout.addWidget(distribution_quad, 0, 1) + main_layout.addWidget(pattern_quad, 1, 0) + main_layout.addWidget(timing_quad, 1, 1) + + # Make all quadrants equal + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + + def create_basic_quadrant(self): + """Create basic settings quadrant - guaranteed no overlaps""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Root Note - 12 buttons in 3 rows of 4 + root_label = QLabel("Root Note:") + layout.addWidget(root_label) + + root_container = QWidget() + root_layout = QGridLayout(root_container) + root_layout.setSpacing(4) + root_layout.setContentsMargins(0, 0, 0, 0) + + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + for i, note in enumerate(notes): + btn = QPushButton(note) + btn.setFixedSize(35, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, n=i: self.on_root_note_clicked(n)) + + if i == 0: # C is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.root_note_buttons[i] = btn + root_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(root_container) + + # Octave - 6 buttons in 2 rows of 3 + octave_label = QLabel("Octave:") + layout.addWidget(octave_label) + + octave_container = QWidget() + octave_layout = QGridLayout(octave_container) + octave_layout.setSpacing(4) + octave_layout.setContentsMargins(0, 0, 0, 0) + + for i in range(6): # C3 to C8 + octave = i + 3 + btn = QPushButton(f"C{octave}") + btn.setFixedSize(35, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, o=octave: self.on_octave_clicked(o)) + + if octave == 4: # C4 is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.octave_buttons[octave] = btn + octave_layout.addWidget(btn, i // 3, i % 3) + + layout.addWidget(octave_container) + + # Scales - 8 main scales in 4 rows of 2 + scale_label = QLabel("Scale:") + layout.addWidget(scale_label) + + scale_container = QWidget() + scale_layout = QGridLayout(scale_container) + scale_layout.setSpacing(4) + scale_layout.setContentsMargins(0, 0, 0, 0) + + main_scales = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "pentatonic_major", "pentatonic_minor"] + for i, scale in enumerate(main_scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 8: + display_name = display_name[:8] + + btn = QPushButton(display_name) + btn.setFixedSize(75, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, s=scale: self.on_scale_clicked(s)) + + if scale == "major": # Major is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.scale_buttons[scale] = btn + scale_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(scale_container) + + # Octave Range dropdown + range_label = QLabel("Octave Range:") + layout.addWidget(range_label) + + self.octave_range_combo = QComboBox() + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + self.octave_range_combo.setCurrentIndex(0) + layout.addWidget(self.octave_range_combo) + + return group + + def create_distribution_quadrant(self): + """Create channel distribution quadrant""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Distribution patterns - 8 patterns in 4 rows of 2 + dist_label = QLabel("Distribution Pattern:") + layout.addWidget(dist_label) + + dist_container = QWidget() + dist_layout = QGridLayout(dist_container) + dist_layout.setSpacing(4) + dist_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "bounce", "random", "cycle", "alternating", "single_channel"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(85, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, p=pattern: self.on_distribution_clicked(p)) + + if pattern == "up": # Up is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.distribution_buttons[pattern] = btn + dist_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(dist_container) + + # Description + self.distribution_description = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.distribution_description.setStyleSheet("color: #666; font-style: italic; font-size: 10px;") + self.distribution_description.setWordWrap(True) + layout.addWidget(self.distribution_description) + + return group + + def create_pattern_quadrant(self): + """Create pattern settings quadrant""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Arpeggio patterns - 8 patterns in 4 rows of 2 + pattern_label = QLabel("Arpeggio Pattern:") + layout.addWidget(pattern_label) + + pattern_container = QWidget() + pattern_layout = QGridLayout(pattern_container) + pattern_layout.setSpacing(4) + pattern_layout.setContentsMargins(0, 0, 0, 0) + + patterns = self.arpeggiator.PATTERN_TYPES[:8] # First 8 patterns + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(85, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, p=pattern: self.on_pattern_clicked(p)) + + if pattern == "up": # Up is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.pattern_buttons[pattern] = btn + pattern_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(pattern_container) + + return group + + def create_timing_quadrant(self): + """Create timing settings quadrant""" + group = QGroupBox("Timing Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Tempo + tempo_layout = QHBoxLayout() + tempo_layout.addWidget(QLabel("Tempo:")) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + tempo_layout.addWidget(self.tempo_spin) + layout.addLayout(tempo_layout) + + # Note Speed + speed_layout = QHBoxLayout() + speed_layout.addWidget(QLabel("Speed:")) + self.speed_combo = QComboBox() + speeds = list(self.arpeggiator.NOTE_SPEEDS.keys()) + for speed in speeds: + self.speed_combo.addItem(speed) + self.speed_combo.setCurrentText("1/8") + speed_layout.addWidget(self.speed_combo) + layout.addLayout(speed_layout) + + # Gate + gate_layout = QHBoxLayout() + gate_layout.addWidget(QLabel("Gate:")) + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) + self.gate_slider.setValue(100) + gate_layout.addWidget(self.gate_slider) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(35) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout) + + # Swing + swing_layout = QHBoxLayout() + swing_layout.addWidget(QLabel("Swing:")) + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + swing_layout.addWidget(self.swing_slider) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(35) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout) + + # Velocity + velocity_layout = QHBoxLayout() + velocity_layout.addWidget(QLabel("Velocity:")) + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + velocity_layout.addWidget(self.velocity_slider) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(35) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout) + + # Preset controls + preset_layout = QHBoxLayout() + self.save_preset_btn = QPushButton("Save") + self.save_preset_btn.setFixedSize(50, 25) + self.load_preset_btn = QPushButton("Load") + self.load_preset_btn.setFixedSize(50, 25) + preset_layout.addWidget(self.save_preset_btn) + preset_layout.addWidget(self.load_preset_btn) + preset_layout.addStretch() + layout.addLayout(preset_layout) + + return group + + def set_button_style(self, button, state): + """Set button style based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 10px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 10px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 10px; + } + """) + + def connect_signals(self): + """Connect all signals""" + # Timing controls + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + self.speed_combo.currentTextChanged.connect(self.on_speed_changed) + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + + # Preset controls + self.save_preset_btn.clicked.connect(self.save_current_preset) + self.load_preset_btn.clicked.connect(self.load_preset_dialog) + + # Arpeggiator signals + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + # Event handlers + def on_root_note_clicked(self, note_index): + """Handle root note button click""" + midi_note = self.current_octave * 12 + note_index + + if self.arpeggiator.is_playing: + self.arm_root_note(note_index) + self.arpeggiator.arm_root_note(midi_note) + else: + self.set_active_root_note(note_index) + self.arpeggiator.set_root_note(midi_note) + + def on_octave_clicked(self, octave): + """Handle octave button click""" + midi_note = octave * 12 + self.current_root_note + + if self.arpeggiator.is_playing: + self.arm_octave(octave) + self.arpeggiator.arm_root_note(midi_note) + else: + self.set_active_octave(octave) + self.arpeggiator.set_root_note(midi_note) + + def on_scale_clicked(self, scale): + """Handle scale button click""" + if self.arpeggiator.is_playing: + self.arm_scale(scale) + self.arpeggiator.arm_scale(scale) + else: + self.set_active_scale(scale) + self.arpeggiator.set_scale(scale) + + def on_pattern_clicked(self, pattern): + """Handle pattern button click""" + if self.arpeggiator.is_playing: + self.arm_pattern(pattern) + self.arpeggiator.arm_pattern_type(pattern) + else: + self.set_active_pattern(pattern) + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_clicked(self, distribution): + """Handle distribution button click""" + if self.arpeggiator.is_playing: + self.arm_distribution(distribution) + self.arpeggiator.arm_channel_distribution(distribution) + else: + self.set_active_distribution(distribution) + self.arpeggiator.set_channel_distribution(distribution) + + # State management + def set_active_root_note(self, note_index): + """Set active root note""" + if self.current_root_note in self.root_note_buttons: + self.set_button_style(self.root_note_buttons[self.current_root_note], "inactive") + + self.current_root_note = note_index + if note_index in self.root_note_buttons: + self.set_button_style(self.root_note_buttons[note_index], "active") + + def set_active_octave(self, octave): + """Set active octave""" + if self.current_octave in self.octave_buttons: + self.set_button_style(self.octave_buttons[self.current_octave], "inactive") + + self.current_octave = octave + if octave in self.octave_buttons: + self.set_button_style(self.octave_buttons[octave], "active") + + def set_active_scale(self, scale): + """Set active scale""" + if self.current_scale in self.scale_buttons: + self.set_button_style(self.scale_buttons[self.current_scale], "inactive") + + self.current_scale = scale + if scale in self.scale_buttons: + self.set_button_style(self.scale_buttons[scale], "active") + + def set_active_pattern(self, pattern): + """Set active pattern""" + if self.current_pattern in self.pattern_buttons: + self.set_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + self.current_pattern = pattern + if pattern in self.pattern_buttons: + self.set_button_style(self.pattern_buttons[pattern], "active") + + def set_active_distribution(self, distribution): + """Set active distribution""" + if self.current_distribution in self.distribution_buttons: + self.set_button_style(self.distribution_buttons[self.current_distribution], "inactive") + + self.current_distribution = distribution + if distribution in self.distribution_buttons: + self.set_button_style(self.distribution_buttons[distribution], "active") + self.update_distribution_description(distribution) + + def arm_root_note(self, note_index): + """Arm root note for pattern-end change""" + if self.armed_root_note_button: + self.set_button_style(self.armed_root_note_button, "inactive") + + self.armed_root_note_button = self.root_note_buttons[note_index] + self.set_button_style(self.armed_root_note_button, "armed") + + def arm_octave(self, octave): + """Arm octave for pattern-end change""" + if self.armed_octave_button: + self.set_button_style(self.armed_octave_button, "inactive") + + self.armed_octave_button = self.octave_buttons[octave] + self.set_button_style(self.armed_octave_button, "armed") + + def arm_scale(self, scale): + """Arm scale for pattern-end change""" + if self.armed_scale_button: + self.set_button_style(self.armed_scale_button, "inactive") + + self.armed_scale_button = self.scale_buttons[scale] + self.set_button_style(self.armed_scale_button, "armed") + + def arm_pattern(self, pattern): + """Arm pattern for pattern-end change""" + if self.armed_pattern_button: + self.set_button_style(self.armed_pattern_button, "inactive") + + self.armed_pattern_button = self.pattern_buttons[pattern] + self.set_button_style(self.armed_pattern_button, "armed") + + def arm_distribution(self, distribution): + """Arm distribution for pattern-end change""" + if self.armed_distribution_button: + self.set_button_style(self.armed_distribution_button, "inactive") + + self.armed_distribution_button = self.distribution_buttons[distribution] + self.set_button_style(self.armed_distribution_button, "armed") + + @pyqtSlot() + def update_armed_states(self): + """Update when armed states are applied""" + if self.armed_root_note_button and self.arpeggiator.armed_root_note is None: + note_index = None + for n, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + note_index = n + break + if note_index is not None: + self.set_active_root_note(note_index) + + if self.armed_octave_button and self.arpeggiator.armed_root_note is None: + octave = None + for o, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + octave = o + break + if octave is not None: + self.set_active_octave(octave) + + if self.armed_scale_button and self.arpeggiator.armed_scale is None: + scale = None + for s, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + scale = s + break + if scale: + self.set_active_scale(scale) + + if self.armed_pattern_button and self.arpeggiator.armed_pattern_type is None: + pattern = None + for p, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + pattern = p + break + if pattern: + self.set_active_pattern(pattern) + + if self.armed_distribution_button and self.arpeggiator.armed_channel_distribution is None: + distribution = None + for d, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + distribution = d + break + if distribution: + self.set_active_distribution(distribution) + + def update_distribution_description(self, distribution: str): + """Update distribution description""" + descriptions = { + "up": "Channels: 1 → 2 → 3 → 4 → 5 → 6...", + "down": "Channels: 6 → 5 → 4 → 3 → 2 → 1...", + "up_down": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "bounce": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "random": "Channels: Random selection each note", + "cycle": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 1 → 2...", + "alternating": "Channels: 1 → 6 → 2 → 5 → 3 → 4...", + "single_channel": "Channels: All notes on channel 1" + } + self.distribution_description.setText(descriptions.get(distribution, "Unknown pattern")) + + # Timing control handlers + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + self.arpeggiator.set_tempo(float(tempo)) + + @pyqtSlot(str) + def on_speed_changed(self, speed): + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_gate_changed(self, gate_percent): + self.arpeggiator.set_gate(gate_percent / 100.0) + self.gate_label.setText(f"{gate_percent}%") + + @pyqtSlot(int) + def on_swing_changed(self, swing_percent): + self.arpeggiator.set_swing(swing_percent / 100.0) + self.swing_label.setText(f"{swing_percent}%") + + @pyqtSlot(int) + def on_velocity_changed(self, velocity): + self.arpeggiator.set_velocity(velocity) + self.velocity_label.setText(str(velocity)) + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + octaves = index + 1 + self.arpeggiator.set_octave_range(octaves) + + # Preset system + def save_current_preset(self): + """Save current settings as preset""" + preset_name = f"Preset_{len(self.presets) + 1}" + preset = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution, + 'octave_range': self.octave_range_combo.currentIndex(), + 'tempo': self.tempo_spin.value(), + 'speed': self.speed_combo.currentText(), + 'gate': self.gate_slider.value(), + 'swing': self.swing_slider.value(), + 'velocity': self.velocity_slider.value() + } + self.presets[preset_name] = preset + print(f"Saved {preset_name}") + + def load_preset_dialog(self): + """Load next preset""" + if not self.presets: + print("No presets saved") + return + + preset_names = list(self.presets.keys()) + if self.current_preset in preset_names: + current_index = preset_names.index(self.current_preset) + next_index = (current_index + 1) % len(preset_names) + else: + next_index = 0 + + self.load_preset(preset_names[next_index]) + + def load_preset(self, preset_name: str): + """Load specific preset""" + if preset_name not in self.presets: + return + + preset = self.presets[preset_name] + self.current_preset = preset_name + + # Apply settings + if self.arpeggiator.is_playing: + # Arm changes + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.arm_root_note(midi_note) + self.arpeggiator.arm_scale(preset['scale']) + self.arpeggiator.arm_pattern_type(preset['pattern']) + self.arpeggiator.arm_channel_distribution(preset['distribution']) + else: + # Apply immediately + self.set_active_root_note(preset['root_note']) + self.set_active_octave(preset['octave']) + self.set_active_scale(preset['scale']) + self.set_active_pattern(preset['pattern']) + self.set_active_distribution(preset['distribution']) + + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.set_root_note(midi_note) + self.arpeggiator.set_scale(preset['scale']) + self.arpeggiator.set_pattern_type(preset['pattern']) + self.arpeggiator.set_channel_distribution(preset['distribution']) + + # Apply other settings + self.octave_range_combo.setCurrentIndex(preset['octave_range']) + self.tempo_spin.setValue(preset['tempo']) + self.speed_combo.setCurrentText(preset['speed']) + self.gate_slider.setValue(preset['gate']) + self.swing_slider.setValue(preset['swing']) + self.velocity_slider.setValue(preset['velocity']) + + print(f"Loaded {preset_name}") \ No newline at end of file diff --git a/gui/channel_controls.py b/gui/channel_controls.py new file mode 100644 index 0000000..224a668 --- /dev/null +++ b/gui/channel_controls.py @@ -0,0 +1,233 @@ +""" +Channel Controls GUI + +Interface for managing MIDI channels, instruments, and synth configuration. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSpinBox, QLabel, QPushButton, + QScrollArea, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ChannelControls(QWidget): + """Control panel for MIDI channel management""" + + def __init__(self, channel_manager, output_manager): + super().__init__() + self.channel_manager = channel_manager + self.output_manager = output_manager + self.channel_widgets = {} + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Global Settings + global_group = self.create_global_settings() + layout.addWidget(global_group) + + # Individual Channel Settings + channels_group = self.create_channel_settings() + layout.addWidget(channels_group) + + def create_global_settings(self) -> QGroupBox: + """Create global channel settings""" + group = QGroupBox("Global Settings") + layout = QGridLayout(group) + + # Active Synth Count + layout.addWidget(QLabel("Active Synths:"), 0, 0) + self.synth_count_spin = QSpinBox() + self.synth_count_spin.setRange(1, 16) + self.synth_count_spin.setValue(8) + layout.addWidget(self.synth_count_spin, 0, 1) + + # Global Instrument + layout.addWidget(QLabel("Global Instrument:"), 1, 0) + global_layout = QHBoxLayout() + self.global_instrument_combo = QComboBox() + self.populate_instrument_combo(self.global_instrument_combo) + self.apply_global_button = QPushButton("Apply to All") + global_layout.addWidget(self.global_instrument_combo) + global_layout.addWidget(self.apply_global_button) + layout.addLayout(global_layout, 1, 1) + + return group + + def create_channel_settings(self) -> QGroupBox: + """Create individual channel settings""" + group = QGroupBox("Individual Channels") + layout = QVBoxLayout(group) + + # Scroll area for channel controls + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMaximumHeight(400) + + # Widget to contain all channel controls + scroll_widget = QWidget() + scroll_layout = QVBoxLayout(scroll_widget) + + # Create controls for each channel + for channel in range(1, 17): + channel_widget = self.create_single_channel_control(channel) + scroll_layout.addWidget(channel_widget) + self.channel_widgets[channel] = channel_widget + + scroll.setWidget(scroll_widget) + layout.addWidget(scroll) + + return group + + def create_single_channel_control(self, channel: int) -> QFrame: + """Create controls for a single channel""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + layout = QHBoxLayout(frame) + + # Channel label + channel_label = QLabel(f"Ch {channel}:") + channel_label.setFixedWidth(40) + channel_label.setStyleSheet("font-weight: bold;") + layout.addWidget(channel_label) + + # Instrument selection + instrument_combo = QComboBox() + instrument_combo.setFixedWidth(200) + self.populate_instrument_combo(instrument_combo) + layout.addWidget(instrument_combo) + + # Voice count display + voice_label = QLabel("Voices: 0/3") + voice_label.setFixedWidth(80) + layout.addWidget(voice_label) + + # Status indicator + status_label = QLabel("●") + status_label.setStyleSheet("color: #666666; font-size: 16px;") + status_label.setFixedWidth(20) + layout.addWidget(status_label) + + layout.addStretch() + + # Store references for easy access + frame.instrument_combo = instrument_combo + frame.voice_label = voice_label + frame.status_label = status_label + frame.channel = channel + + # Connect instrument change + instrument_combo.currentIndexChanged.connect( + lambda idx, ch=channel: self.on_channel_instrument_changed(ch, idx) + ) + + return frame + + def populate_instrument_combo(self, combo: QComboBox): + """Populate combo box with GM instruments""" + for i, name in enumerate(self.channel_manager.GM_PROGRAMS): + combo.addItem(f"{i:03d}: {name}", i) + + def connect_signals(self): + """Connect signals and slots""" + # Global settings + self.synth_count_spin.valueChanged.connect(self.on_synth_count_changed) + self.apply_global_button.clicked.connect(self.on_apply_global_instrument) + + # Channel manager signals + self.channel_manager.active_synth_count_changed.connect(self.on_active_count_changed) + self.channel_manager.channel_instrument_changed.connect(self.on_instrument_changed) + self.channel_manager.voice_allocation_changed.connect(self.on_voice_allocation_changed) + + @pyqtSlot(int) + def on_synth_count_changed(self, count): + """Handle active synth count change""" + self.channel_manager.set_active_synth_count(count) + self.update_channel_visibility() + + @pyqtSlot() + def on_apply_global_instrument(self): + """Apply global instrument to all active channels""" + program = self.global_instrument_combo.currentData() + if program is not None: + self.channel_manager.set_all_instruments(program) + # Send program changes via output manager + for channel in self.channel_manager.get_active_channels(): + self.output_manager.send_program_change(channel, program) + + @pyqtSlot(int, int) + def on_channel_instrument_changed(self, channel, combo_index): + """Handle individual channel instrument change""" + combo = self.channel_widgets[channel].instrument_combo + program = combo.itemData(combo_index) + if program is not None: + self.channel_manager.set_channel_instrument(channel, program) + self.output_manager.send_program_change(channel, program) + + @pyqtSlot(int) + def on_active_count_changed(self, count): + """Handle active synth count change from channel manager""" + self.synth_count_spin.setValue(count) + self.update_channel_visibility() + + @pyqtSlot(int, int) + def on_instrument_changed(self, channel, program): + """Handle instrument change from channel manager""" + if channel in self.channel_widgets: + combo = self.channel_widgets[channel].instrument_combo + # Find and select the correct item + for i in range(combo.count()): + if combo.itemData(i) == program: + combo.setCurrentIndex(i) + break + + @pyqtSlot(int, list) + def on_voice_allocation_changed(self, channel, active_notes): + """Handle voice allocation change""" + if channel in self.channel_widgets: + voice_count = len(active_notes) + max_voices = self.channel_manager.max_voices_per_synth + voice_label = self.channel_widgets[channel].voice_label + voice_label.setText(f"Voices: {voice_count}/{max_voices}") + + # Update status indicator + status_label = self.channel_widgets[channel].status_label + if voice_count > 0: + if voice_count >= max_voices: + status_label.setStyleSheet("color: #aa6600; font-size: 16px;") # Orange - full + else: + status_label.setStyleSheet("color: #00aa00; font-size: 16px;") # Green - active + else: + status_label.setStyleSheet("color: #666666; font-size: 16px;") # Gray - inactive + + def update_channel_visibility(self): + """Update visibility of channel controls based on active count""" + active_count = self.channel_manager.active_synth_count + + for channel, widget in self.channel_widgets.items(): + if channel <= active_count: + widget.show() + widget.setStyleSheet("") # Active appearance + else: + widget.hide() + # Could also use different styling instead of hiding + # widget.setStyleSheet("color: #666666;") # Grayed out + + def refresh_all_channels(self): + """Refresh all channel displays""" + for channel in range(1, 17): + if channel in self.channel_widgets: + # Update instrument display + program = self.channel_manager.get_channel_instrument(channel) + if program is not None: + combo = self.channel_widgets[channel].instrument_combo + for i in range(combo.count()): + if combo.itemData(i) == program: + combo.setCurrentIndex(i) + break + + # Update voice count + voices = self.channel_manager.get_active_voices(channel) + self.on_voice_allocation_changed(channel, voices) \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..926d7d8 --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,628 @@ +""" +Main Window GUI + +Primary application window with all controls and displays. +Integrates all GUI components into a cohesive interface. +""" + +from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGridLayout, QPushButton, QLabel, QSlider, QComboBox, + QSpinBox, QGroupBox, QTabWidget, QSplitter, QFrame) +from PyQt5.QtCore import Qt, QTimer, pyqtSlot +from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence + +from .arpeggiator_controls import ArpeggiatorControls +from .channel_controls import ChannelControls +from .volume_controls import VolumeControls +from .simulator_display import SimulatorDisplay +from .output_controls import OutputControls +from .preset_controls import PresetControls + +class MainWindow(QMainWindow): + """ + Main application window containing all GUI components. + Provides organized layout and coordinates between different control panels. + """ + + def __init__(self, arpeggiator, channel_manager, volume_engine, output_manager, simulator, maschine_controller=None): + super().__init__() + + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + self.output_manager = output_manager + self.simulator = simulator + self.maschine_controller = maschine_controller + + self.setWindowTitle("MIDI Arpeggiator - Lighting Controller") + self.setMinimumSize(1200, 800) + + # Keyboard note mapping + self.keyboard_notes = { + Qt.Key_A: 60, # C + Qt.Key_W: 61, # C# + Qt.Key_S: 62, # D + Qt.Key_E: 63, # D# + Qt.Key_D: 64, # E + Qt.Key_F: 65, # F + Qt.Key_T: 66, # F# + Qt.Key_G: 67, # G + Qt.Key_Y: 68, # G# + Qt.Key_H: 69, # A + Qt.Key_U: 70, # A# + Qt.Key_J: 71, # B + Qt.Key_K: 72, # C (next octave) + } + self.held_keys = set() + + self.setup_ui() + self.setup_connections() + self.apply_dark_theme() + + def setup_ui(self): + """Initialize the user interface""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Create main layout with full-window tabs + main_layout = QVBoxLayout(central_widget) + + # Transport controls at top + transport_frame = self.create_transport_controls() + main_layout.addWidget(transport_frame) + + # Create tabbed interface that fills the window + tab_widget = QTabWidget() + main_layout.addWidget(tab_widget) + + # Arpeggiator tab with quadrant layout + self.arp_controls = ArpeggiatorControls(self.arpeggiator, self.channel_manager, self.simulator) + tab_widget.addTab(self.arp_controls, "Arpeggiator") + + # Channels tab + self.channel_controls = ChannelControls(self.channel_manager, self.output_manager) + tab_widget.addTab(self.channel_controls, "Channels") + + # Volume/Lighting tab + self.volume_controls = VolumeControls(self.volume_engine) + tab_widget.addTab(self.volume_controls, "Volume/Lighting") + + # Output tab + self.output_controls = OutputControls(self.output_manager) + tab_widget.addTab(self.output_controls, "Output") + + # Simulator display now integrated into arpeggiator tab - removed standalone tab + + # Presets tab + self.preset_controls = PresetControls(self.arpeggiator, self.channel_manager, self.volume_engine) + tab_widget.addTab(self.preset_controls, "Presets") + + # Status display at bottom + status_frame = self.create_status_display() + main_layout.addWidget(status_frame) + + # Create status bar + self.statusBar().showMessage("Ready - Use keyboard (AWSDFGTGHYUJ) to play notes, SPACE to start/stop") + + # Create menu bar + self.create_menu_bar() + + # Removed create_control_panel and create_display_panel methods - now using direct tab layout + + def create_transport_controls(self) -> QFrame: + """Create transport control buttons""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + frame.setMaximumHeight(60) # Limit height to just accommodate buttons + layout = QHBoxLayout(frame) + + # Play/Stop buttons + self.play_button = QPushButton("▶ PLAY") + self.play_button.setObjectName("playButton") + self.stop_button = QPushButton("⏹ STOP") + self.stop_button.setObjectName("stopButton") + self.panic_button = QPushButton("⚠ PANIC") + self.panic_button.setObjectName("panicButton") + + # Style buttons + button_style = """ + QPushButton { + font-size: 14px; + font-weight: bold; + padding: 10px 20px; + border-radius: 5px; + } + QPushButton:hover { + background-color: #404040; + } + """ + + play_style = button_style + """ + QPushButton { + background-color: #2d5a2d; + color: white; + } + """ + + stop_style = button_style + """ + QPushButton { + background-color: #5a2d2d; + color: white; + } + """ + + panic_style = button_style + """ + QPushButton { + background-color: #5a2d5a; + color: white; + } + """ + + self.play_button.setStyleSheet(play_style) + self.stop_button.setStyleSheet(stop_style) + self.panic_button.setStyleSheet(panic_style) + + # Connect buttons + self.play_button.clicked.connect(self.on_play_clicked) + self.stop_button.clicked.connect(self.on_stop_clicked) + self.panic_button.clicked.connect(self.on_panic_clicked) + + # Add to layout + layout.addWidget(self.play_button) + layout.addWidget(self.stop_button) + layout.addWidget(self.panic_button) + layout.addStretch() + + # Tempo display + tempo_label = QLabel("Tempo:") + self.tempo_display = QLabel("120 BPM") + self.tempo_display.setStyleSheet("font-weight: bold; font-size: 14px;") + + layout.addWidget(tempo_label) + layout.addWidget(self.tempo_display) + + return frame + + def create_status_display(self) -> QFrame: + """Create status information display""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + frame.setMaximumHeight(60) + layout = QHBoxLayout(frame) + + # Output mode indicator + self.mode_indicator = QLabel("Mode: Simulator") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;") + + # Connection status + self.connection_status = QLabel("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + + # Active voices count + self.voices_display = QLabel("Voices: 0") + + layout.addWidget(QLabel("Status:")) + layout.addWidget(self.mode_indicator) + layout.addWidget(QFrame()) # Separator + layout.addWidget(self.connection_status) + layout.addWidget(QFrame()) # Separator + layout.addWidget(self.voices_display) + layout.addStretch() + + return frame + + def create_menu_bar(self): + """Create application menu bar""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu('File') + file_menu.addAction('New Preset', self.preset_controls.new_preset) + file_menu.addAction('Load Preset', self.preset_controls.load_preset) + file_menu.addAction('Save Preset', self.preset_controls.save_preset) + file_menu.addSeparator() + file_menu.addAction('Exit', self.close) + + # View menu + view_menu = menubar.addMenu('View') + view_menu.addAction('Reset Layout', self.reset_layout) + + # MIDI menu + midi_menu = menubar.addMenu('MIDI') + midi_menu.addAction('Refresh Devices', self.output_controls.refresh_midi_devices) + midi_menu.addAction('Panic (All Notes Off)', self.on_panic_clicked) + + # Help menu + help_menu = menubar.addMenu('Help') + help_menu.addAction('About', self.show_about) + + def setup_connections(self): + """Connect signals and slots""" + # Arpeggiator signals + self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed) + self.arpeggiator.tempo_changed.connect(self.on_tempo_changed) + + # Output manager signals + self.output_manager.mode_changed.connect(self.on_output_mode_changed) + + # Simulator signals now only connected to embedded display in arpeggiator controls + + # Connect signals to embedded simulator display in arpeggiator controls + if hasattr(self.arp_controls, 'simulator_display'): + self.channel_manager.active_synth_count_changed.connect( + self.arp_controls.simulator_display.set_synth_count + ) + # Disabled lighting_updated connection - using MIDI volume changes instead + # self.simulator.lighting_updated.connect( + # self.arp_controls.simulator_display.update_lighting + # ) + self.arpeggiator.note_triggered.connect( + self.arp_controls.simulator_display.on_note_played + ) + # Connect MIDI volume changes to display + self.output_manager.volume_sent.connect( + self.arp_controls.simulator_display.on_midi_volume_changed + ) + + # Initialize display with current volume values + for channel in range(1, 17): + current_volume = self.output_manager.get_channel_volume(channel) + self.arp_controls.simulator_display.on_midi_volume_changed(channel, current_volume) + + # Update timer for status display + self.status_timer = QTimer() + self.status_timer.timeout.connect(self.update_status_display) + self.status_timer.start(100) # Update 10 times per second + + def apply_dark_theme(self): + """Apply modern dark theme optimized for live performance""" + dark_palette = QPalette() + + # Modern dark colors + dark_palette.setColor(QPalette.Window, QColor(32, 32, 36)) + dark_palette.setColor(QPalette.WindowText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Base, QColor(18, 18, 20)) + dark_palette.setColor(QPalette.AlternateBase, QColor(42, 42, 46)) + dark_palette.setColor(QPalette.ToolTipBase, QColor(0, 0, 0)) + dark_palette.setColor(QPalette.ToolTipText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Text, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Button, QColor(48, 48, 52)) + dark_palette.setColor(QPalette.ButtonText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.BrightText, QColor(255, 100, 100)) + dark_palette.setColor(QPalette.Link, QColor(100, 200, 255)) + dark_palette.setColor(QPalette.Highlight, QColor(0, 150, 255)) + dark_palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + + self.setPalette(dark_palette) + + # Modern performance-oriented styling + self.setStyleSheet(""" + /* Main Window */ + QMainWindow { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #202024, stop:1 #181820); + } + + /* Tabs - More prominent for live use */ + QTabWidget::pane { + border: 2px solid #00aaff; + border-radius: 8px; + background-color: #2a2a2e; + } + QTabBar::tab { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #404044, stop:1 #353538); + color: white; + padding: 12px 20px; + margin: 2px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + min-width: 100px; + font-size: 12px; + font-weight: bold; + } + QTabBar::tab:selected { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #0099ff, stop:1 #0077cc); + color: white; + border: 2px solid #00aaff; + } + QTabBar::tab:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #505054, stop:1 #454548); + } + + /* Group Boxes - Better organization */ + QGroupBox { + font-weight: bold; + font-size: 13px; + color: #ffffff; + border: 2px solid #00aaff; + border-radius: 10px; + margin-top: 20px; + padding-top: 15px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #2a2a2e, stop:1 #242428); + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #00aaff; + background-color: #2a2a2e; + border-radius: 5px; + } + + /* Buttons - Individual styling only */ + QPushButton { + color: white; + font-weight: bold; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #505054, stop:1 #454548); + border: 2px solid #00aaff; + } + QPushButton:pressed { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #353538, stop:1 #2a2a2e); + border: 2px solid #0088cc; + } + + /* Combo Boxes - Cleaner look */ + QComboBox { + background: #353538; + border: 2px solid #555555; + border-radius: 5px; + color: white; + padding: 5px 10px; + min-width: 120px; + } + QComboBox:hover { + border: 2px solid #00aaff; + } + QComboBox::drop-down { + border: none; + width: 20px; + } + QComboBox::down-arrow { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid white; + margin-right: 5px; + } + + /* Sliders - Better for real-time control */ + QSlider::groove:horizontal { + border: 1px solid #555555; + height: 8px; + background: #2a2a2e; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #00aaff, stop:1 #0088cc); + border: 2px solid #ffffff; + width: 20px; + margin: -8px 0; + border-radius: 10px; + } + QSlider::handle:horizontal:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #00ccff, stop:1 #00aacc); + } + + /* Spin Boxes */ + QSpinBox { + background: #353538; + border: 2px solid #555555; + border-radius: 5px; + color: white; + padding: 5px; + min-width: 60px; + } + QSpinBox:hover { + border: 2px solid #00aaff; + } + + /* Labels - Better contrast */ + QLabel { + color: #ffffff; + font-size: 11px; + } + + /* Status Bar - More prominent */ + QStatusBar { + background: #1a1a1e; + color: #00aaff; + border-top: 1px solid #555555; + font-weight: bold; + padding: 5px; + } + + /* Transport Controls - Special styling */ + QPushButton#playButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #2d5a2d, stop:1 #1a3d1a); + border: 2px solid #4a8a4a; + color: #aaffaa; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#playButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #3d6a3d, stop:1 #2a4d2a); + border: 2px solid #5aaa5a; + } + + QPushButton#stopButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5a2d2d, stop:1 #3d1a1a); + border: 2px solid #8a4a4a; + color: #ffaaaa; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#stopButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #6a3d3d, stop:1 #4d2a2a); + border: 2px solid #aa5a5a; + } + + QPushButton#panicButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5a2d5a, stop:1 #3d1a3d); + border: 2px solid #8a4a8a; + color: #ffaaff; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#panicButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #6a3d6a, stop:1 #4d2a4d); + border: 2px solid #aa5aaa; + } + """) + + @pyqtSlot() + def on_play_clicked(self): + """Handle play button click""" + # If no notes are held, add some default notes for testing + if not self.arpeggiator.held_notes: + # Add a C major chord for testing + self.arpeggiator.note_on(60) # C + self.arpeggiator.note_on(64) # E + self.arpeggiator.note_on(67) # G + self.statusBar().showMessage("Added test notes (C major chord)", 2000) + + success = self.arpeggiator.start() + if not success: + self.statusBar().showMessage("Could not start arpeggiator", 3000) + + @pyqtSlot() + def on_stop_clicked(self): + """Handle stop button click""" + self.arpeggiator.stop() + + @pyqtSlot() + def on_panic_clicked(self): + """Handle panic button click""" + self.output_manager.send_panic() + self.statusBar().showMessage("Panic sent - all notes off", 2000) + + @pyqtSlot(bool) + def on_playing_state_changed(self, is_playing): + """Handle arpeggiator play state change""" + if is_playing: + self.play_button.setText("⏸ Pause") + self.statusBar().showMessage("Arpeggiator playing") + else: + self.play_button.setText("▶ Play") + self.statusBar().showMessage("Arpeggiator stopped") + + @pyqtSlot(float) + def on_tempo_changed(self, tempo): + """Handle tempo change""" + self.tempo_display.setText(f"{tempo:.1f} BPM") + + @pyqtSlot(str) + def on_output_mode_changed(self, mode): + """Handle output mode change""" + if mode == "simulator": + self.mode_indicator.setText("Mode: Simulator") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;") + else: + self.mode_indicator.setText("Mode: Hardware") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #aaaa00;") + + def update_status_display(self): + """Update status display information""" + # Update connection status + if self.output_manager.is_connected(): + self.connection_status.setText("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + else: + self.connection_status.setText("Disconnected") + self.connection_status.setStyleSheet("color: #aa0000;") + + # Update active voices count + if self.output_manager.current_mode == "simulator": + voice_count = self.simulator.get_active_voices_count() + else: + voice_count = sum(len(voices) for voices in self.channel_manager.active_voices.values()) + + self.voices_display.setText(f"Voices: {voice_count}") + + def reset_layout(self): + """Reset window layout to default""" + # This could restore default sizes, positions, etc. + self.statusBar().showMessage("Layout reset", 2000) + + def show_about(self): + """Show about dialog""" + from PyQt5.QtWidgets import QMessageBox + QMessageBox.about(self, "About MIDI Arpeggiator", + "MIDI Arpeggiator with Lighting Control\n\n" + "A modular arpeggiator for controlling synthesizers\n" + "and synchronized lighting effects.\n\n" + "Features:\n" + "• FL Studio-style arpeggiator patterns\n" + "• Multi-synth routing and voice management\n" + "• Volume/brightness pattern generation\n" + "• Built-in simulator mode\n" + "• Native Instruments Maschine integration") + + def keyPressEvent(self, event): + """Handle key press for note input""" + key = event.key() + + # Avoid key repeat + if event.isAutoRepeat(): + return + + if key in self.keyboard_notes: + note = self.keyboard_notes[key] + if note not in self.held_keys: + self.held_keys.add(note) + self.arpeggiator.note_on(note) + self.statusBar().showMessage(f"Note ON: {note}", 500) + + elif key == Qt.Key_Space: + # Spacebar starts/stops arpeggiator + if self.arpeggiator.is_playing: + self.on_stop_clicked() + else: + self.on_play_clicked() + + super().keyPressEvent(event) + + def keyReleaseEvent(self, event): + """Handle key release for note input""" + key = event.key() + + # Avoid key repeat + if event.isAutoRepeat(): + return + + if key in self.keyboard_notes: + note = self.keyboard_notes[key] + if note in self.held_keys: + self.held_keys.remove(note) + self.arpeggiator.note_off(note) + self.statusBar().showMessage(f"Note OFF: {note}", 500) + + super().keyReleaseEvent(event) + + def closeEvent(self, event): + """Handle window close event""" + # Clean up resources + self.arpeggiator.stop() + self.output_manager.close() + self.simulator.cleanup() + event.accept() \ No newline at end of file diff --git a/gui/output_controls.py b/gui/output_controls.py new file mode 100644 index 0000000..e3a3932 --- /dev/null +++ b/gui/output_controls.py @@ -0,0 +1,268 @@ +""" +Output Controls GUI + +Interface for managing MIDI output mode and device selection. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QPushButton, QLabel, + QRadioButton, QButtonGroup, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class OutputControls(QWidget): + """Control panel for MIDI output management""" + + def __init__(self, output_manager): + super().__init__() + self.output_manager = output_manager + self.setup_ui() + self.connect_signals() + self.refresh_midi_devices() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Output Mode Selection + mode_group = self.create_mode_selection() + layout.addWidget(mode_group) + + # Hardware MIDI Settings + midi_group = self.create_midi_settings() + layout.addWidget(midi_group) + + # Status and Controls + status_group = self.create_status_controls() + layout.addWidget(status_group) + + layout.addStretch() + + def create_mode_selection(self) -> QGroupBox: + """Create output mode selection controls""" + group = QGroupBox("Output Mode") + layout = QVBoxLayout(group) + + # Radio buttons for mode selection + self.mode_group = QButtonGroup() + + self.simulator_radio = QRadioButton("Simulator Mode") + self.simulator_radio.setChecked(True) # Default + self.simulator_radio.setToolTip("Use internal audio synthesis and visual lighting simulation") + + self.hardware_radio = QRadioButton("Hardware Mode") + self.hardware_radio.setToolTip("Send MIDI to external hardware synths") + + self.mode_group.addButton(self.simulator_radio, 0) + self.mode_group.addButton(self.hardware_radio, 1) + + layout.addWidget(self.simulator_radio) + layout.addWidget(self.hardware_radio) + + # Mode description + self.mode_description = QLabel("Using internal simulator with audio synthesis and lighting visualization.") + self.mode_description.setWordWrap(True) + self.mode_description.setStyleSheet("color: #888888; font-style: italic;") + layout.addWidget(self.mode_description) + + return group + + def create_midi_settings(self) -> QGroupBox: + """Create MIDI device settings""" + group = QGroupBox("Hardware MIDI Settings") + layout = QGridLayout(group) + + # MIDI Output Device + layout.addWidget(QLabel("MIDI Output:"), 0, 0) + self.midi_device_combo = QComboBox() + self.midi_device_combo.setMinimumWidth(200) + layout.addWidget(self.midi_device_combo, 0, 1) + + self.refresh_button = QPushButton("Refresh") + self.refresh_button.clicked.connect(self.refresh_midi_devices) + layout.addWidget(self.refresh_button, 0, 2) + + # Connection status + layout.addWidget(QLabel("Status:"), 1, 0) + self.connection_status = QLabel("Not Connected") + self.connection_status.setStyleSheet("color: #aa6600;") + layout.addWidget(self.connection_status, 1, 1) + + # Initially disable MIDI settings (simulator mode default) + self.set_midi_controls_enabled(False) + + return group + + def create_status_controls(self) -> QGroupBox: + """Create status display and control buttons""" + group = QGroupBox("Controls") + layout = QGridLayout(group) + + # Panic button + self.panic_button = QPushButton("🚨 Panic (All Notes Off)") + self.panic_button.setStyleSheet(""" + QPushButton { + background-color: #5a2d5a; + color: white; + font-weight: bold; + padding: 8px; + border-radius: 4px; + } + QPushButton:hover { + background-color: #6a3d6a; + } + """) + layout.addWidget(self.panic_button, 0, 0, 1, 2) + + # Test button + self.test_button = QPushButton("Test Output") + self.test_button.setToolTip("Send a test note to verify output is working") + layout.addWidget(self.test_button, 1, 0) + + # Status display + status_frame = QFrame() + status_frame.setFrameStyle(QFrame.Box) + status_layout = QVBoxLayout(status_frame) + + self.output_info = QLabel("Mode: Simulator\\nDevice: Internal\\nStatus: Ready") + self.output_info.setStyleSheet("font-family: monospace;") + status_layout.addWidget(self.output_info) + + layout.addWidget(status_frame, 2, 0, 1, 2) + + return group + + def connect_signals(self): + """Connect signals and slots""" + # Mode selection + self.mode_group.buttonClicked.connect(self.on_mode_changed) + + # MIDI device selection + self.midi_device_combo.currentTextChanged.connect(self.on_midi_device_changed) + + # Control buttons + self.panic_button.clicked.connect(self.on_panic_clicked) + self.test_button.clicked.connect(self.on_test_clicked) + + # Output manager signals + self.output_manager.mode_changed.connect(self.on_output_mode_changed) + self.output_manager.midi_device_changed.connect(self.on_output_device_changed) + self.output_manager.error_occurred.connect(self.on_output_error) + + def set_midi_controls_enabled(self, enabled: bool): + """Enable/disable MIDI hardware controls""" + self.midi_device_combo.setEnabled(enabled) + self.refresh_button.setEnabled(enabled) + + if enabled: + self.connection_status.setText("Ready for connection") + self.connection_status.setStyleSheet("color: #aaaa00;") + else: + self.connection_status.setText("Simulator Mode") + self.connection_status.setStyleSheet("color: #00aa00;") + + @pyqtSlot() + def refresh_midi_devices(self): + """Refresh MIDI device list""" + self.output_manager.refresh_midi_devices() + devices = self.output_manager.get_available_outputs() + + self.midi_device_combo.clear() + if devices: + for device in devices: + self.midi_device_combo.addItem(device) + else: + self.midi_device_combo.addItem("No MIDI devices found") + + # Update status + self.update_status_display() + + @pyqtSlot() + def on_mode_changed(self): + """Handle output mode change""" + if self.simulator_radio.isChecked(): + self.output_manager.set_mode("simulator") + self.set_midi_controls_enabled(False) + self.mode_description.setText( + "Using internal simulator with audio synthesis and lighting visualization." + ) + else: + self.output_manager.set_mode("hardware") + self.set_midi_controls_enabled(True) + self.mode_description.setText( + "Sending MIDI to external hardware synthesizers. " + "Select MIDI output device below." + ) + + @pyqtSlot(str) + def on_midi_device_changed(self, device_name: str): + """Handle MIDI device selection change""" + if device_name and device_name != "No MIDI devices found": + success = self.output_manager.set_midi_output(device_name) + if success: + self.connection_status.setText("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + else: + self.connection_status.setText("Connection Failed") + self.connection_status.setStyleSheet("color: #aa0000;") + + self.update_status_display() + + @pyqtSlot() + def on_panic_clicked(self): + """Handle panic button click""" + self.output_manager.send_panic() + + @pyqtSlot() + def on_test_clicked(self): + """Handle test button click""" + # Send a test note (Middle C for 500ms) + self.output_manager.send_note_on(1, 60, 80) # Channel 1, Middle C, velocity 80 + + # Schedule note off after 500ms + from PyQt5.QtCore import QTimer + QTimer.singleShot(500, lambda: self.output_manager.send_note_off(1, 60)) + + @pyqtSlot(str) + def on_output_mode_changed(self, mode: str): + """Handle mode change from output manager""" + if mode == "simulator": + self.simulator_radio.setChecked(True) + else: + self.hardware_radio.setChecked(True) + + self.update_status_display() + + @pyqtSlot(str) + def on_output_device_changed(self, device: str): + """Handle device change from output manager""" + # Find and select the device in combo box + index = self.midi_device_combo.findText(device) + if index >= 0: + self.midi_device_combo.setCurrentIndex(index) + + self.update_status_display() + + @pyqtSlot(str) + def on_output_error(self, error_message: str): + """Handle output error""" + self.connection_status.setText("Error") + self.connection_status.setStyleSheet("color: #aa0000;") + self.connection_status.setToolTip(error_message) + + def update_status_display(self): + """Update the status information display""" + status_info = self.output_manager.get_status_info() + + mode = status_info.get('mode', 'Unknown') + connected = status_info.get('connected', False) + device = status_info.get('selected_output', 'None') + + if mode == "simulator": + device_text = "Internal Simulator" + status_text = "Ready" + else: + device_text = device if device else "None Selected" + status_text = "Connected" if connected else "Disconnected" + + info_text = f"Mode: {mode.title()}\\nDevice: {device_text}\\nStatus: {status_text}" + self.output_info.setText(info_text) \ No newline at end of file diff --git a/gui/preset_controls.py b/gui/preset_controls.py new file mode 100644 index 0000000..fa77f62 --- /dev/null +++ b/gui/preset_controls.py @@ -0,0 +1,510 @@ +""" +Preset Controls GUI + +Interface for saving, loading, and managing presets. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QListWidget, QPushButton, QLineEdit, + QLabel, QFileDialog, QMessageBox, QListWidgetItem, + QInputDialog, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot +import json +import os + +class PresetControls(QWidget): + """Control panel for preset management""" + + def __init__(self, arpeggiator, channel_manager, volume_engine): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + + # Preset storage + self.presets = {} + self.current_preset = None + self.presets_directory = "presets" + + # Ensure presets directory exists + os.makedirs(self.presets_directory, exist_ok=True) + + self.setup_ui() + self.load_presets_from_directory() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Preset list + preset_group = self.create_preset_list() + layout.addWidget(preset_group) + + # Preset operations + operations_group = self.create_operations() + layout.addWidget(operations_group) + + # File operations + file_group = self.create_file_operations() + layout.addWidget(file_group) + + def create_preset_list(self) -> QGroupBox: + """Create preset list display""" + group = QGroupBox("Presets") + layout = QVBoxLayout(group) + + self.preset_list = QListWidget() + self.preset_list.setMaximumHeight(200) + self.preset_list.itemClicked.connect(self.on_preset_selected) + self.preset_list.itemDoubleClicked.connect(self.on_preset_double_clicked) + layout.addWidget(self.preset_list) + + # Current preset indicator + current_layout = QHBoxLayout() + current_layout.addWidget(QLabel("Current:")) + self.current_preset_label = QLabel("None") + self.current_preset_label.setStyleSheet("font-weight: bold; color: #00aa00;") + current_layout.addWidget(self.current_preset_label) + current_layout.addStretch() + layout.addLayout(current_layout) + + return group + + def create_operations(self) -> QGroupBox: + """Create preset operation buttons""" + group = QGroupBox("Operations") + layout = QGridLayout(group) + + # Load preset + self.load_button = QPushButton("Load Preset") + self.load_button.setEnabled(False) + self.load_button.clicked.connect(self.load_selected_preset) + layout.addWidget(self.load_button, 0, 0) + + # Save current as new preset + self.save_new_button = QPushButton("Save as New...") + self.save_new_button.clicked.connect(self.save_new_preset) + layout.addWidget(self.save_new_button, 0, 1) + + # Update selected preset + self.update_button = QPushButton("Update Selected") + self.update_button.setEnabled(False) + self.update_button.clicked.connect(self.update_selected_preset) + layout.addWidget(self.update_button, 1, 0) + + # Delete preset + self.delete_button = QPushButton("Delete Selected") + self.delete_button.setEnabled(False) + self.delete_button.clicked.connect(self.delete_selected_preset) + self.delete_button.setStyleSheet("color: #aa6666;") + layout.addWidget(self.delete_button, 1, 1) + + # Rename preset + self.rename_button = QPushButton("Rename Selected") + self.rename_button.setEnabled(False) + self.rename_button.clicked.connect(self.rename_selected_preset) + layout.addWidget(self.rename_button, 2, 0) + + # Duplicate preset + self.duplicate_button = QPushButton("Duplicate Selected") + self.duplicate_button.setEnabled(False) + self.duplicate_button.clicked.connect(self.duplicate_selected_preset) + layout.addWidget(self.duplicate_button, 2, 1) + + return group + + def create_file_operations(self) -> QGroupBox: + """Create file operation buttons""" + group = QGroupBox("File Operations") + layout = QHBoxLayout(group) + + # Import preset + self.import_button = QPushButton("Import Preset...") + self.import_button.clicked.connect(self.import_preset) + layout.addWidget(self.import_button) + + # Export preset + self.export_button = QPushButton("Export Selected...") + self.export_button.setEnabled(False) + self.export_button.clicked.connect(self.export_selected_preset) + layout.addWidget(self.export_button) + + return group + + def capture_current_settings(self) -> dict: + """Capture current settings into a preset dictionary""" + preset = { + "version": "1.0", + "timestamp": None, # Will be set when saving + + # Arpeggiator settings + "arpeggiator": { + "root_note": self.arpeggiator.root_note, + "scale": self.arpeggiator.scale, + "pattern_type": self.arpeggiator.pattern_type, + "octave_range": self.arpeggiator.octave_range, + "note_speed": self.arpeggiator.note_speed, + "gate": self.arpeggiator.gate, + "swing": self.arpeggiator.swing, + "velocity": self.arpeggiator.velocity, + "tempo": self.arpeggiator.tempo + }, + + # Channel settings + "channels": { + "active_synth_count": self.channel_manager.active_synth_count, + "channel_instruments": self.channel_manager.channel_instruments.copy() + }, + + # Volume pattern settings + "volume_patterns": { + "current_pattern": self.volume_engine.current_pattern, + "pattern_speed": self.volume_engine.pattern_speed, + "pattern_intensity": self.volume_engine.pattern_intensity, + "global_volume_range": self.volume_engine.global_volume_range, + "global_velocity_range": self.volume_engine.global_velocity_range, + "channel_volume_ranges": self.volume_engine.channel_volume_ranges.copy(), + "velocity_ranges": self.volume_engine.velocity_ranges.copy() + } + } + + return preset + + def apply_preset_settings(self, preset: dict): + """Apply preset settings to the system""" + try: + # Apply arpeggiator settings + arp_settings = preset.get("arpeggiator", {}) + self.arpeggiator.set_root_note(arp_settings.get("root_note", 60)) + self.arpeggiator.set_scale(arp_settings.get("scale", "major")) + self.arpeggiator.set_pattern_type(arp_settings.get("pattern_type", "up")) + self.arpeggiator.set_octave_range(arp_settings.get("octave_range", 1)) + self.arpeggiator.set_note_speed(arp_settings.get("note_speed", "1/8")) + self.arpeggiator.set_gate(arp_settings.get("gate", 1.0)) + self.arpeggiator.set_swing(arp_settings.get("swing", 0.0)) + self.arpeggiator.set_velocity(arp_settings.get("velocity", 80)) + self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0)) + + # Apply channel settings + channel_settings = preset.get("channels", {}) + self.channel_manager.set_active_synth_count( + channel_settings.get("active_synth_count", 8) + ) + + # Apply instruments + instruments = channel_settings.get("channel_instruments", {}) + for channel_str, program in instruments.items(): + channel = int(channel_str) + self.channel_manager.set_channel_instrument(channel, program) + + # Apply volume pattern settings + volume_settings = preset.get("volume_patterns", {}) + self.volume_engine.set_pattern(volume_settings.get("current_pattern", "static")) + self.volume_engine.set_pattern_speed(volume_settings.get("pattern_speed", 1.0)) + self.volume_engine.set_pattern_intensity(volume_settings.get("pattern_intensity", 1.0)) + + # Apply global ranges + global_vol = volume_settings.get("global_volume_range", (0.2, 1.0)) + global_vel = volume_settings.get("global_velocity_range", (40, 127)) + self.volume_engine.set_global_ranges( + global_vol[0], global_vol[1], global_vel[0], global_vel[1] + ) + + # Apply individual channel ranges + ch_vol_ranges = volume_settings.get("channel_volume_ranges", {}) + for channel_str, range_tuple in ch_vol_ranges.items(): + channel = int(channel_str) + self.volume_engine.set_channel_volume_range(channel, range_tuple[0], range_tuple[1]) + + vel_ranges = volume_settings.get("velocity_ranges", {}) + for channel_str, range_tuple in vel_ranges.items(): + channel = int(channel_str) + self.volume_engine.set_velocity_range(channel, range_tuple[0], range_tuple[1]) + + except Exception as e: + QMessageBox.warning(self, "Preset Error", f"Error applying preset: {str(e)}") + + @pyqtSlot(QListWidgetItem) + def on_preset_selected(self, item): + """Handle preset selection""" + preset_name = item.text() + + # Enable/disable buttons based on selection + has_selection = preset_name is not None + self.load_button.setEnabled(has_selection) + self.update_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + self.rename_button.setEnabled(has_selection) + self.duplicate_button.setEnabled(has_selection) + self.export_button.setEnabled(has_selection) + + @pyqtSlot(QListWidgetItem) + def on_preset_double_clicked(self, item): + """Handle preset double-click (load preset)""" + self.load_selected_preset() + + @pyqtSlot() + def load_selected_preset(self): + """Load the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + if preset_name in self.presets: + self.apply_preset_settings(self.presets[preset_name]) + self.current_preset = preset_name + self.current_preset_label.setText(preset_name) + + # Visual feedback + current_item.setBackground(Qt.darkGreen) + for i in range(self.preset_list.count()): + item = self.preset_list.item(i) + if item != current_item: + item.setBackground(Qt.transparent) + + @pyqtSlot() + def save_new_preset(self): + """Save current settings as a new preset""" + name, ok = QInputDialog.getText(self, "New Preset", "Enter preset name:") + if ok and name: + if name in self.presets: + reply = QMessageBox.question( + self, "Overwrite Preset", + f"Preset '{name}' already exists. Overwrite?", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + + # Capture current settings + preset_data = self.capture_current_settings() + preset_data["timestamp"] = self.get_current_timestamp() + + # Save preset + self.presets[name] = preset_data + self.save_preset_to_file(name, preset_data) + + # Update list + self.refresh_preset_list() + + # Select the new preset + items = self.preset_list.findItems(name, Qt.MatchExactly) + if items: + self.preset_list.setCurrentItem(items[0]) + + @pyqtSlot() + def update_selected_preset(self): + """Update the selected preset with current settings""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + reply = QMessageBox.question( + self, "Update Preset", + f"Update preset '{preset_name}' with current settings?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + preset_data = self.capture_current_settings() + preset_data["timestamp"] = self.get_current_timestamp() + + self.presets[preset_name] = preset_data + self.save_preset_to_file(preset_name, preset_data) + + @pyqtSlot() + def delete_selected_preset(self): + """Delete the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + reply = QMessageBox.question( + self, "Delete Preset", + f"Delete preset '{preset_name}'? This cannot be undone.", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # Remove from memory and file + if preset_name in self.presets: + del self.presets[preset_name] + + preset_file = os.path.join(self.presets_directory, f"{preset_name}.json") + if os.path.exists(preset_file): + os.remove(preset_file) + + # Update list + self.refresh_preset_list() + + # Clear current if it was deleted + if self.current_preset == preset_name: + self.current_preset = None + self.current_preset_label.setText("None") + + @pyqtSlot() + def rename_selected_preset(self): + """Rename the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + old_name = current_item.text() + new_name, ok = QInputDialog.getText(self, "Rename Preset", "Enter new name:", text=old_name) + + if ok and new_name and new_name != old_name: + if new_name in self.presets: + QMessageBox.warning(self, "Rename Error", f"Preset '{new_name}' already exists.") + return + + # Move preset data + self.presets[new_name] = self.presets[old_name] + del self.presets[old_name] + + # Handle files + old_file = os.path.join(self.presets_directory, f"{old_name}.json") + new_file = os.path.join(self.presets_directory, f"{new_name}.json") + + if os.path.exists(old_file): + os.rename(old_file, new_file) + + # Update current preset reference + if self.current_preset == old_name: + self.current_preset = new_name + self.current_preset_label.setText(new_name) + + # Refresh list + self.refresh_preset_list() + + @pyqtSlot() + def duplicate_selected_preset(self): + """Duplicate the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + source_name = current_item.text() + new_name, ok = QInputDialog.getText(self, "Duplicate Preset", "Enter name for copy:", text=f"{source_name} Copy") + + if ok and new_name: + if new_name in self.presets: + QMessageBox.warning(self, "Duplicate Error", f"Preset '{new_name}' already exists.") + return + + # Copy preset data + self.presets[new_name] = self.presets[source_name].copy() + self.save_preset_to_file(new_name, self.presets[new_name]) + + # Refresh list and select new preset + self.refresh_preset_list() + items = self.preset_list.findItems(new_name, Qt.MatchExactly) + if items: + self.preset_list.setCurrentItem(items[0]) + + @pyqtSlot() + def import_preset(self): + """Import a preset from file""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Import Preset", "", "JSON Files (*.json);;All Files (*)" + ) + + if file_path: + try: + with open(file_path, 'r') as f: + preset_data = json.load(f) + + # Get name from user + default_name = os.path.splitext(os.path.basename(file_path))[0] + name, ok = QInputDialog.getText(self, "Import Preset", "Preset name:", text=default_name) + + if ok and name: + self.presets[name] = preset_data + self.save_preset_to_file(name, preset_data) + self.refresh_preset_list() + + except Exception as e: + QMessageBox.critical(self, "Import Error", f"Error importing preset: {str(e)}") + + @pyqtSlot() + def export_selected_preset(self): + """Export the selected preset to file""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + file_path, _ = QFileDialog.getSaveFileName( + self, "Export Preset", f"{preset_name}.json", "JSON Files (*.json);;All Files (*)" + ) + + if file_path: + try: + with open(file_path, 'w') as f: + json.dump(self.presets[preset_name], f, indent=2) + + except Exception as e: + QMessageBox.critical(self, "Export Error", f"Error exporting preset: {str(e)}") + + def load_presets_from_directory(self): + """Load all presets from the presets directory""" + if not os.path.exists(self.presets_directory): + return + + for filename in os.listdir(self.presets_directory): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + file_path = os.path.join(self.presets_directory, filename) + + try: + with open(file_path, 'r') as f: + preset_data = json.load(f) + self.presets[preset_name] = preset_data + except Exception as e: + print(f"Error loading preset {filename}: {e}") + + self.refresh_preset_list() + + def save_preset_to_file(self, name: str, preset_data: dict): + """Save a preset to file""" + file_path = os.path.join(self.presets_directory, f"{name}.json") + try: + with open(file_path, 'w') as f: + json.dump(preset_data, f, indent=2) + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Error saving preset: {str(e)}") + + def refresh_preset_list(self): + """Refresh the preset list display""" + self.preset_list.clear() + for name in sorted(self.presets.keys()): + item = QListWidgetItem(name) + if name == self.current_preset: + item.setBackground(Qt.darkGreen) + self.preset_list.addItem(item) + + def get_current_timestamp(self) -> str: + """Get current timestamp string""" + from datetime import datetime + return datetime.now().isoformat() + + def new_preset(self): + """Create a new preset (for menu action)""" + self.save_new_preset() + + def load_preset(self): + """Load preset (for menu action)""" + if self.preset_list.currentItem(): + self.load_selected_preset() + + def save_preset(self): + """Save preset (for menu action)""" + if self.preset_list.currentItem(): + self.update_selected_preset() + else: + self.save_new_preset() \ No newline at end of file diff --git a/gui/simulator_display.py b/gui/simulator_display.py new file mode 100644 index 0000000..49e3efc --- /dev/null +++ b/gui/simulator_display.py @@ -0,0 +1,173 @@ +""" +Simulator Display GUI + +Simplified visual representation showing only channel volumes. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QFrame, QPushButton, QSlider, QGroupBox) +from PyQt5.QtCore import Qt, QTimer, pyqtSlot +from PyQt5.QtGui import QPainter, QColor, QBrush, QPen, QFont + +class SynthWidget(QFrame): + """Individual synth display widget - simplified to show only volume""" + + def __init__(self, channel: int): + super().__init__() + self.channel = channel + self.channel_volume_midi = 100 # 0-127 MIDI CC7 value + + self.setFrameStyle(QFrame.Box) + self.setFixedSize(100, 80) + self.setStyleSheet(""" + QFrame { + border: 2px solid #404040; + border-radius: 6px; + background-color: #2a2a2a; + } + """) + + def set_channel_volume(self, volume_midi: int): + """Set channel volume (0-127 MIDI CC7) - this is what controls brightness""" + self.channel_volume_midi = max(0, min(127, volume_midi)) + self.update() # Trigger repaint + + def paintEvent(self, event): + """Custom paint for volume-based lighting""" + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Calculate brightness based on MIDI volume (0-127) + brightness_intensity = int((self.channel_volume_midi / 127.0) * 255) + + # Create color based on channel (different hues) + hue = (self.channel - 1) * 360 / 16 # Distribute hues across spectrum + color = QColor.fromHsv(int(hue), 200, brightness_intensity + 20) + + # Draw volume-based lighting effect + if self.channel_volume_midi > 6: # Only show if volume is above ~5% (6/127) + # Draw volume glow + glow_rect = self.rect().adjusted(8, 8, -8, -8) + painter.setBrush(QBrush(color)) + painter.setPen(QPen(color.lighter(120), 1)) + painter.drawRoundedRect(glow_rect, 4, 4) + + # Draw channel info + painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setFont(QFont("Arial", 11, QFont.Bold)) + + # Channel number + painter.drawText(10, 20, f"Ch {self.channel}") + + # Volume level (MIDI CC7 value) + painter.setFont(QFont("Arial", 9)) + painter.drawText(10, 65, f"Vol: {self.channel_volume_midi}") + + +class SimulatorDisplay(QWidget): + """Simplified simulator display widget""" + + def __init__(self, simulator, channel_manager): + super().__init__() + self.simulator = simulator + self.channel_manager = channel_manager + self.synth_widgets = {} + self.current_synth_count = 8 + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Title + title = QLabel("Synth Array - Channel Volume Display") + title.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin: 5px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # Synth display grid + self.synth_grid_widget = QWidget() + self.synth_grid_layout = QGridLayout(self.synth_grid_widget) + self.synth_grid_layout.setSpacing(5) + layout.addWidget(self.synth_grid_widget) + + # Initialize with default synth count + self.create_synth_widgets(self.current_synth_count) + + def connect_signals(self): + """Connect signals""" + # Channel manager signals + if hasattr(self.channel_manager, 'volume_changed'): + self.channel_manager.volume_changed.connect(self.on_channel_volume_changed) + + def create_synth_widgets(self, count: int): + """Create synth widgets for display""" + # Clear existing widgets + for widget in self.synth_widgets.values(): + widget.deleteLater() + self.synth_widgets.clear() + + # Clear layout + while self.synth_grid_layout.count(): + child = self.synth_grid_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + # Create new widgets + cols = 4 # 4 synths per row + for i in range(count): + channel = i + 1 + widget = SynthWidget(channel) + + row = i // cols + col = i % cols + self.synth_grid_layout.addWidget(widget, row, col) + + self.synth_widgets[channel] = widget + + self.current_synth_count = count + + @pyqtSlot(int) + def set_synth_count(self, count: int): + """Set number of synths to display""" + if count != self.current_synth_count: + self.create_synth_widgets(count) + + @pyqtSlot(int, float) + def on_channel_volume_changed(self, channel: int, volume: float): + """Handle channel volume change""" + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) + + @pyqtSlot(int, int, int, float) + def on_note_played(self, channel: int, note: int, velocity: int, duration: float): + """Handle note played - we don't need special handling for this in volume mode""" + # In the simplified version, we only care about volume, not individual notes + pass + + @pyqtSlot(int, int) + def on_midi_volume_changed(self, channel: int, volume: int): + """Handle MIDI volume change (CC7) - channel 1-16, volume 0-127""" + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) + + @pyqtSlot(int, float, float) + @pyqtSlot(dict) + def update_lighting(self, lighting_data: dict): + """Update lighting based on channel volume from simulator engine""" + for channel, brightness in lighting_data.items(): + if channel in self.synth_widgets: + # Convert brightness (0-1) to MIDI (0-127) for display consistency + midi_volume = int(brightness * 127) + self.synth_widgets[channel].set_channel_volume(midi_volume) + + def update_channel_volumes_from_output_manager(self, output_manager): + """Update all channel volumes from output manager""" + for channel in range(1, self.current_synth_count + 1): + volume = output_manager.get_channel_volume(channel) / 127.0 # Convert from MIDI to 0-1 + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) \ No newline at end of file diff --git a/gui/volume_controls.py b/gui/volume_controls.py new file mode 100644 index 0000000..a175f16 --- /dev/null +++ b/gui/volume_controls.py @@ -0,0 +1,317 @@ +""" +Volume Controls GUI + +Interface for tempo-linked volume and brightness pattern controls. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QFrame, QScrollArea) +from PyQt5.QtCore import Qt, pyqtSlot + +class VolumeControls(QWidget): + """Control panel for tempo-linked volume and brightness patterns""" + + # Tempo-linked pattern types with bar lengths + TEMPO_PATTERNS = { + "static": "Static", + "1_bar_swell": "1 Bar Swell", + "2_bar_swell": "2 Bar Swell", + "4_bar_swell": "4 Bar Swell", + "8_bar_swell": "8 Bar Swell", + "1_bar_breathing": "1 Bar Breathing", + "2_bar_breathing": "2 Bar Breathing", + "4_bar_breathing": "4 Bar Breathing", + "1_bar_wave": "1 Bar Wave", + "2_bar_wave": "2 Bar Wave", + "4_bar_wave": "4 Bar Wave", + "cascade_up": "Cascade Up", + "cascade_down": "Cascade Down", + "random_sparkle": "Random Sparkle" + } + + def __init__(self, volume_engine): + super().__init__() + self.volume_engine = volume_engine + self.current_pattern = "static" + self.armed_pattern_button = None + self.pattern_buttons = {} + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Tempo-Linked Pattern Settings + pattern_group = self.create_pattern_settings() + layout.addWidget(pattern_group) + + # Global Range Settings (keep min/max volume and velocity) + global_group = self.create_global_settings() + layout.addWidget(global_group) + + layout.addStretch() + + def create_pattern_settings(self) -> QGroupBox: + """Create tempo-linked volume pattern settings""" + group = QGroupBox("Tempo-Linked Volume Patterns") + layout = QVBoxLayout(group) + + # Description + desc = QLabel("Volume changes once per note per channel, linked to arpeggiator tempo") + desc.setStyleSheet("color: #888888; font-style: italic;") + desc.setWordWrap(True) + layout.addWidget(desc) + + # Pattern buttons + pattern_widget = self.create_pattern_buttons() + layout.addWidget(pattern_widget) + + return group + + def create_pattern_buttons(self) -> QWidget: + """Create pattern selection buttons""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(3) + + row = 0 + col = 0 + + for pattern_key, display_name in self.TEMPO_PATTERNS.items(): + button = QPushButton(display_name) + button.setCheckable(True) + button.clicked.connect(lambda checked, p=pattern_key: self.on_pattern_button_clicked(p)) + + # Set initial state + if pattern_key == "static": + button.setChecked(True) + self.update_pattern_button_style(button, "active") + else: + self.update_pattern_button_style(button, "inactive") + + self.pattern_buttons[pattern_key] = button + layout.addWidget(button, row, col) + + col += 1 + if col >= 3: # 3 buttons per row + col = 0 + row += 1 + + return widget + + def update_pattern_button_style(self, button, state): + """Update pattern button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + min-height: 30px; + padding: 5px 10px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + min-height: 30px; + padding: 5px 10px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + min-height: 30px; + padding: 5px 10px; + } + """) + + def create_global_settings(self) -> QGroupBox: + """Create global volume/velocity range settings""" + group = QGroupBox("Global Volume Range") + layout = QGridLayout(group) + + # Global Volume Range + layout.addWidget(QLabel("Volume Range:"), 0, 0) + vol_layout = QVBoxLayout() + + # Min Volume + min_vol_layout = QHBoxLayout() + min_vol_layout.addWidget(QLabel("Min:")) + self.min_volume_slider = QSlider(Qt.Horizontal) + self.min_volume_slider.setRange(0, 100) # 0% to 100% + self.min_volume_slider.setValue(10) # 10% for subtle lighting + self.min_volume_label = QLabel("10%") + self.min_volume_label.setFixedWidth(40) + min_vol_layout.addWidget(self.min_volume_slider) + min_vol_layout.addWidget(self.min_volume_label) + vol_layout.addLayout(min_vol_layout) + + # Max Volume + max_vol_layout = QHBoxLayout() + max_vol_layout.addWidget(QLabel("Max:")) + self.max_volume_slider = QSlider(Qt.Horizontal) + self.max_volume_slider.setRange(0, 100) # 0% to 100% + self.max_volume_slider.setValue(100) # 100% + self.max_volume_label = QLabel("100%") + self.max_volume_label.setFixedWidth(40) + max_vol_layout.addWidget(self.max_volume_slider) + max_vol_layout.addWidget(self.max_volume_label) + vol_layout.addLayout(max_vol_layout) + + layout.addLayout(vol_layout, 0, 1) + + # Global Velocity Range + layout.addWidget(QLabel("Velocity Range:"), 1, 0) + vel_layout = QVBoxLayout() + + # Min Velocity + min_vel_layout = QHBoxLayout() + min_vel_layout.addWidget(QLabel("Min:")) + self.min_velocity_slider = QSlider(Qt.Horizontal) + self.min_velocity_slider.setRange(1, 127) + self.min_velocity_slider.setValue(40) + self.min_velocity_label = QLabel("40") + self.min_velocity_label.setFixedWidth(40) + min_vel_layout.addWidget(self.min_velocity_slider) + min_vel_layout.addWidget(self.min_velocity_label) + vel_layout.addLayout(min_vel_layout) + + # Max Velocity + max_vel_layout = QHBoxLayout() + max_vel_layout.addWidget(QLabel("Max:")) + self.max_velocity_slider = QSlider(Qt.Horizontal) + self.max_velocity_slider.setRange(1, 127) + self.max_velocity_slider.setValue(127) + self.max_velocity_label = QLabel("127") + self.max_velocity_label.setFixedWidth(40) + max_vel_layout.addWidget(self.max_velocity_slider) + max_vel_layout.addWidget(self.max_velocity_label) + vel_layout.addLayout(max_vel_layout) + + layout.addLayout(vel_layout, 1, 1) + + return group + + def connect_signals(self): + """Connect GUI controls to volume engine""" + # Volume range controls + self.min_volume_slider.valueChanged.connect(self.on_min_volume_changed) + self.max_volume_slider.valueChanged.connect(self.on_max_volume_changed) + self.min_velocity_slider.valueChanged.connect(self.on_min_velocity_changed) + self.max_velocity_slider.valueChanged.connect(self.on_max_velocity_changed) + + def on_pattern_button_clicked(self, pattern): + """Handle pattern button click""" + # Note: We'll need to modify this to work with arpeggiator playing state + # For now, apply immediately + self.set_active_pattern(pattern) + + # Reset pattern position when changing patterns + self.volume_engine.reset_pattern() + + # Map our tempo patterns to volume engine patterns + if pattern == "static": + self.volume_engine.set_pattern("static") + elif "swell" in pattern: + self.volume_engine.set_pattern("swell") + # Set appropriate speed based on bar length + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) # Faster for 1 bar + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) # Normal speed + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) # Slower for 4 bars + elif "8_bar" in pattern: + self.volume_engine.set_pattern_speed(0.25) # Very slow for 8 bars + elif "breathing" in pattern: + self.volume_engine.set_pattern("breathing") + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) + elif "wave" in pattern: + self.volume_engine.set_pattern("wave") + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) + elif pattern == "cascade_up": + self.volume_engine.set_pattern("cascade") + elif pattern == "cascade_down": + self.volume_engine.set_pattern("cascade") + elif pattern == "random_sparkle": + self.volume_engine.set_pattern("random_sparkle") + + def set_active_pattern(self, pattern): + """Set active pattern button""" + # Clear current active state + if self.current_pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + # Set new active state + self.current_pattern = pattern + self.update_pattern_button_style(self.pattern_buttons[pattern], "active") + + @pyqtSlot(int) + def on_min_volume_changed(self, value): + """Handle minimum volume change""" + # Ensure min doesn't exceed max + if value >= self.max_volume_slider.value(): + value = self.max_volume_slider.value() - 1 + self.min_volume_slider.setValue(value) + + self.min_volume_label.setText(f"{value}%") + min_vol = value / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_max_volume_changed(self, value): + """Handle maximum volume change""" + # Ensure max doesn't go below min + if value <= self.min_volume_slider.value(): + value = self.min_volume_slider.value() + 1 + self.max_volume_slider.setValue(value) + + self.max_volume_label.setText(f"{value}%") + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = value / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_min_velocity_changed(self, value): + """Handle minimum velocity change""" + # Ensure min doesn't exceed max + if value >= self.max_velocity_slider.value(): + value = self.max_velocity_slider.value() - 1 + self.min_velocity_slider.setValue(value) + + self.min_velocity_label.setText(str(value)) + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, value, self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_max_velocity_changed(self, value): + """Handle maximum velocity change""" + # Ensure max doesn't go below min + if value <= self.min_velocity_slider.value(): + value = self.min_velocity_slider.value() + 1 + self.max_velocity_slider.setValue(value) + + self.max_velocity_label.setText(str(value)) + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), value) \ No newline at end of file diff --git a/install_windows.py b/install_windows.py new file mode 100644 index 0000000..8b5be70 --- /dev/null +++ b/install_windows.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Windows installation script with fallback options for problematic packages. +""" + +import subprocess +import sys +import os + +def run_pip_install(packages): + """Run pip install with error handling""" + for package in packages: + try: + print(f"Installing {package}...") + result = subprocess.run([sys.executable, '-m', 'pip', 'install', package], + capture_output=True, text=True, check=True) + print(f"✓ Successfully installed {package}") + except subprocess.CalledProcessError as e: + print(f"✗ Failed to install {package}: {e}") + print("STDOUT:", e.stdout) + print("STDERR:", e.stderr) + return False + return True + +def install_dependencies(): + """Install dependencies with Windows-specific handling""" + + # Core packages that usually install fine + core_packages = [ + "PyQt5>=5.15.0", + "numpy>=1.21.0", + "pygame>=2.1.0", + "mido>=1.2.10" + ] + + print("Installing core packages...") + if not run_pip_install(core_packages): + print("Failed to install some core packages") + return False + + # Try to install python-rtmidi with different approaches + rtmidi_packages = [ + "python-rtmidi>=1.5.8", # Newer version with better Windows support + "python-rtmidi", # Latest version + "rtmidi-python>=1.1.0" # Alternative package + ] + + rtmidi_installed = False + for package in rtmidi_packages: + print(f"Trying to install {package}...") + if run_pip_install([package]): + rtmidi_installed = True + break + print(f"Failed to install {package}, trying next option...") + + if not rtmidi_installed: + print("\n⚠️ Warning: Could not install any RTMIDI package.") + print("The application will still work in simulator mode.") + print("For hardware MIDI support, you may need to:") + print("1. Install Visual Studio Build Tools") + print("2. Try: pip install --no-cache-dir python-rtmidi") + print("3. Or use the simulator mode only") + + print("\n✓ Installation completed!") + return True + +def create_fallback_rtmidi(): + """Create a fallback rtmidi module using pygame.midi""" + fallback_code = '''""" +Fallback RTMIDI implementation using pygame.midi for Windows compatibility. +""" + +import pygame.midi +import time +from typing import List, Optional, Callable + +class MidiOut: + def __init__(self, device_id): + pygame.midi.init() + self.device_id = device_id + self.midi_out = pygame.midi.Output(device_id) + + def send_message(self, message): + """Send MIDI message""" + if hasattr(message, 'bytes'): + # mido message + data = message.bytes() + else: + # Raw bytes + data = message + + if len(data) == 3: + self.midi_out.write_short(data[0], data[1], data[2]) + elif len(data) == 2: + self.midi_out.write_short(data[0], data[1]) + + def close(self): + if hasattr(self, 'midi_out'): + self.midi_out.close() + +class MidiIn: + def __init__(self, device_id, callback=None): + pygame.midi.init() + self.device_id = device_id + self.midi_in = pygame.midi.Input(device_id) + self.callback = callback + + def set_callback(self, callback): + self.callback = callback + + def poll(self): + """Poll for MIDI input (call this regularly)""" + if self.midi_in.poll() and self.callback: + midi_events = self.midi_in.read(10) + for event in midi_events: + # Convert pygame midi event to mido-like message + if self.callback: + self.callback(event) + + def close(self): + if hasattr(self, 'midi_in'): + self.midi_in.close() + +def get_output_names() -> List[str]: + """Get available MIDI output device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3]: # is_output + devices.append(info[1].decode()) + return devices + +def get_input_names() -> List[str]: + """Get available MIDI input device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2]: # is_input + devices.append(info[1].decode()) + return devices + +def open_output(name: str) -> MidiOut: + """Open MIDI output by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3] and info[1].decode() == name: # is_output and name matches + return MidiOut(i) + raise ValueError(f"MIDI output '{name}' not found") + +def open_input(name: str, callback=None) -> MidiIn: + """Open MIDI input by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2] and info[1].decode() == name: # is_input and name matches + return MidiIn(i, callback) + raise ValueError(f"MIDI input '{name}' not found") +''' + + # Create fallback directory + fallback_dir = os.path.join(os.path.dirname(__file__), 'fallback') + os.makedirs(fallback_dir, exist_ok=True) + + # Write fallback rtmidi module + with open(os.path.join(fallback_dir, 'rtmidi_fallback.py'), 'w') as f: + f.write(fallback_code) + + with open(os.path.join(fallback_dir, '__init__.py'), 'w') as f: + f.write('# Fallback MIDI implementations') + +if __name__ == "__main__": + print("Windows MIDI Arpeggiator Installation Script") + print("=" * 50) + + # Update pip first + print("Updating pip...") + subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'], + capture_output=True) + + # Install dependencies + success = install_dependencies() + + # Create fallback MIDI implementation + create_fallback_rtmidi() + + if success: + print("\n🎉 Installation completed successfully!") + print("\nYou can now run the application with:") + print(" python run.py") + print("\nIf you have MIDI hardware issues, the app will work in simulator mode.") + else: + print("\n⚠️ Installation completed with warnings.") + print("Some packages failed to install, but the app should still work in simulator mode.") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e079e8b --- /dev/null +++ b/main.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +MIDI Arpeggiator - Main Application Entry Point +A modular MIDI arpeggiator with lighting control and Native Instruments Maschine integration +""" + +import sys +import os +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import QTimer + +from gui.main_window import MainWindow +from core.output_manager import OutputManager +from core.arpeggiator_engine import ArpeggiatorEngine +from core.midi_channel_manager import MIDIChannelManager +from core.synth_router import SynthRouter +from core.volume_pattern_engine import VolumePatternEngine +from simulator.simulator_engine import SimulatorEngine +from config.configuration import Configuration +from maschine.maschine_controller import MaschineController + +class ArpeggiatorApp: + def __init__(self): + self.app = QApplication(sys.argv) + self.config = Configuration() + + # Initialize core modules + self.channel_manager = MIDIChannelManager() + self.volume_engine = VolumePatternEngine() + self.synth_router = SynthRouter(self.channel_manager) + self.simulator = SimulatorEngine() + self.output_manager = OutputManager(self.simulator) + self.arpeggiator = ArpeggiatorEngine( + self.channel_manager, + self.synth_router, + self.volume_engine, + self.output_manager + ) + + # Initialize Maschine controller + self.maschine_controller = MaschineController( + self.arpeggiator, + self.channel_manager, + self.volume_engine, + self.synth_router, + self.output_manager + ) + + # Initialize GUI + self.main_window = MainWindow( + self.arpeggiator, + self.channel_manager, + self.volume_engine, + self.output_manager, + self.simulator, + self.maschine_controller + ) + + # Volume changes are now handled directly in update_systems for active channels only + self.previous_active_channels = set() + + # Setup update timer for real-time updates + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update_systems) + self.update_timer.start(16) # ~60 FPS + + # Volume updates are now handled directly in update_systems for active channels only + + def update_systems(self): + """Update all systems that need regular refresh""" + self.arpeggiator.update() + self.simulator.update_lighting_display() + + # Update volume patterns if arpeggiator is playing + if self.arpeggiator.is_playing: + # Advance pattern position (16ms delta at 60fps) + self.volume_engine.update_pattern(0.016) + + # Only update volumes for channels that have active notes + active_channels = set([ch for ch, voices in self.channel_manager.active_voices.items() if voices]) + + if active_channels: + # Update volume patterns for active channels only + for channel in active_channels: + volume = self.volume_engine.get_channel_volume(channel, len(active_channels)) + midi_volume = int(volume * 127) + self.output_manager.send_volume_change(channel, midi_volume) + + # Handle channels that just became inactive + newly_inactive = self.previous_active_channels - active_channels + for channel in newly_inactive: + # Send one CC7 message to reset to default volume + self.output_manager.send_volume_change(channel, 100) + # Dim the visual display + if hasattr(self.main_window.arp_controls, 'simulator_display'): + self.main_window.arp_controls.simulator_display.on_midi_volume_changed(channel, 20) + + # Update previous active channels + self.previous_active_channels = active_channels.copy() + else: + # No active channels - reset all previously active ones + for channel in self.previous_active_channels: + self.output_manager.send_volume_change(channel, 100) + if hasattr(self.main_window.arp_controls, 'simulator_display'): + self.main_window.arp_controls.simulator_display.on_midi_volume_changed(channel, 20) + self.previous_active_channels = set() + + def run(self): + self.main_window.show() + return self.app.exec_() + +if __name__ == "__main__": + app = ArpeggiatorApp() + sys.exit(app.run()) \ No newline at end of file diff --git a/maschine/__init__.py b/maschine/__init__.py new file mode 100644 index 0000000..2013782 --- /dev/null +++ b/maschine/__init__.py @@ -0,0 +1 @@ +# Maschine integration module - Native Instruments Maschine controller interface \ No newline at end of file diff --git a/maschine/__pycache__/__init__.cpython-310.pyc b/maschine/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..475ee27 Binary files /dev/null and b/maschine/__pycache__/__init__.cpython-310.pyc differ diff --git a/maschine/__pycache__/maschine_controller.cpython-310.pyc b/maschine/__pycache__/maschine_controller.cpython-310.pyc new file mode 100644 index 0000000..8ecfd4f Binary files /dev/null and b/maschine/__pycache__/maschine_controller.cpython-310.pyc differ diff --git a/maschine/__pycache__/maschine_interface.cpython-310.pyc b/maschine/__pycache__/maschine_interface.cpython-310.pyc new file mode 100644 index 0000000..9bec327 Binary files /dev/null and b/maschine/__pycache__/maschine_interface.cpython-310.pyc differ diff --git a/maschine/maschine_controller.py b/maschine/maschine_controller.py new file mode 100644 index 0000000..ed6f09a --- /dev/null +++ b/maschine/maschine_controller.py @@ -0,0 +1,332 @@ +""" +Maschine Controller Module + +High-level controller that integrates Maschine hardware with the arpeggiator application. +Handles control mappings and state synchronization. +""" + +from PyQt5.QtCore import QObject, pyqtSlot +from .maschine_interface import MaschineInterface + +class MaschineController(QObject): + """ + High-level controller for Maschine integration. + Bridges Maschine hardware controls with application functionality. + """ + + def __init__(self, arpeggiator, channel_manager, volume_engine, synth_router, output_manager): + super().__init__() + + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + self.synth_router = synth_router + self.output_manager = output_manager + + # Maschine interface + self.maschine = MaschineInterface() + + # State tracking + self.held_notes = set() + self.shift_held = False + self.current_preset_bank = 0 + + # Pattern cycling state + self.pattern_index = 0 + self.volume_pattern_index = 0 + self.routing_pattern_index = 0 + + self.setup_connections() + + def setup_connections(self): + """Setup signal connections""" + # Maschine signals + self.maschine.pad_pressed.connect(self.on_pad_pressed) + self.maschine.pad_released.connect(self.on_pad_released) + self.maschine.encoder_changed.connect(self.on_encoder_changed) + self.maschine.button_pressed.connect(self.on_button_pressed) + + # Application signals for LED feedback + self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed) + self.arpeggiator.note_triggered.connect(self.on_note_triggered) + self.channel_manager.active_synth_count_changed.connect(self.update_channel_leds) + self.volume_engine.pattern_changed.connect(self.update_pattern_leds) + + def connect_maschine(self, input_device: str = None, output_device: str = None) -> bool: + """Connect to Maschine hardware""" + success = self.maschine.connect(input_device, output_device) + if success: + self.update_all_leds() + return success + + def disconnect_maschine(self): + """Disconnect from Maschine hardware""" + self.maschine.disconnect() + + @pyqtSlot(int, int) + def on_pad_pressed(self, pad_number: int, velocity: int): + """Handle pad press events""" + mapping = self.maschine.get_pad_mapping(pad_number) + if not mapping: + return + + if mapping['type'] == 'note_trigger': + note = mapping['note'] + self.held_notes.add(note) + + # Send note to arpeggiator + self.arpeggiator.note_on(note) + + # Visual feedback + self.maschine.send_feedback_pulse(pad_number) + + # If this is the first note and arpeggiator isn't playing, start it + if len(self.held_notes) == 1 and not self.arpeggiator.is_playing: + self.arpeggiator.start() + + @pyqtSlot(int) + def on_pad_released(self, pad_number: int): + """Handle pad release events""" + mapping = self.maschine.get_pad_mapping(pad_number) + if not mapping: + return + + if mapping['type'] == 'note_trigger': + note = mapping['note'] + if note in self.held_notes: + self.held_notes.remove(note) + + # Send note off to arpeggiator + self.arpeggiator.note_off(note) + + @pyqtSlot(int, int) + def on_encoder_changed(self, encoder_number: int, delta: int): + """Handle encoder changes""" + mapping = self.maschine.get_encoder_mapping(encoder_number) + if not mapping: + return + + mapping_type = mapping['type'] + + if mapping_type == 'tempo': + current_tempo = self.arpeggiator.tempo + new_tempo = current_tempo + (delta * mapping['step']) + new_tempo = max(mapping['min'], min(mapping['max'], new_tempo)) + self.arpeggiator.set_tempo(new_tempo) + + elif mapping_type == 'swing': + current_swing = self.arpeggiator.swing * 100 # Convert to percentage + new_swing = current_swing + (delta * mapping['step']) + new_swing = max(mapping['min'], min(mapping['max'], new_swing)) + self.arpeggiator.set_swing(new_swing / 100.0) + + elif mapping_type == 'gate': + current_gate = self.arpeggiator.gate + new_gate = current_gate + (delta * mapping['step']) + new_gate = max(mapping['min'], min(mapping['max'], new_gate)) + self.arpeggiator.set_gate(new_gate) + + elif mapping_type == 'root_note': + current_note = self.arpeggiator.root_note + new_note = current_note + delta + new_note = max(mapping['min'], min(mapping['max'], new_note)) + self.arpeggiator.set_root_note(new_note) + + elif mapping_type == 'pattern_select': + if delta != 0: + patterns = mapping['values'] + self.pattern_index = (self.pattern_index + (1 if delta > 0 else -1)) % len(patterns) + self.arpeggiator.set_pattern_type(patterns[self.pattern_index]) + + elif mapping_type == 'scale_select': + if delta != 0: + scales = mapping['values'] + current_index = scales.index(self.arpeggiator.scale) if self.arpeggiator.scale in scales else 0 + new_index = (current_index + (1 if delta > 0 else -1)) % len(scales) + self.arpeggiator.set_scale(scales[new_index]) + + elif mapping_type == 'volume_pattern': + if delta != 0: + patterns = mapping['values'] + self.volume_pattern_index = (self.volume_pattern_index + (1 if delta > 0 else -1)) % len(patterns) + self.volume_engine.set_pattern(patterns[self.volume_pattern_index]) + + elif mapping_type == 'pattern_speed': + current_speed = self.volume_engine.pattern_speed + new_speed = current_speed + (delta * mapping['step']) + new_speed = max(mapping['min'], min(mapping['max'], new_speed)) + self.volume_engine.set_pattern_speed(new_speed) + + @pyqtSlot(int) + def on_button_pressed(self, button_number: int): + """Handle button press events""" + # Find button name from number + button_name = None + for name, num in self.maschine.MASCHINE_BUTTONS.items(): + if num == button_number: + button_name = name + break + + if not button_name: + return + + mapping = self.maschine.button_mappings.get(button_name) + if not mapping: + return + + mapping_type = mapping['type'] + + if mapping_type == 'preset': + # Load preset (would need preset system integration) + preset_number = mapping['preset_number'] + # self.load_preset(preset_number) + + elif mapping_type == 'output_mode_toggle': + current_mode = self.output_manager.current_mode + new_mode = "hardware" if current_mode == "simulator" else "simulator" + self.output_manager.set_mode(new_mode) + + elif mapping_type == 'routing_pattern_cycle': + patterns = self.synth_router.ROUTING_PATTERNS + self.routing_pattern_index = (self.routing_pattern_index + 1) % len(patterns) + self.synth_router.set_routing_pattern(patterns[self.routing_pattern_index]) + + elif mapping_type == 'volume_pattern_cycle': + patterns = self.volume_engine.PATTERN_TYPES + self.volume_pattern_index = (self.volume_pattern_index + 1) % len(patterns) + self.volume_engine.set_pattern(patterns[self.volume_pattern_index]) + + elif mapping_type == 'panic': + self.output_manager.send_panic() + self.all_notes_off() + + elif mapping_type == 'restart_pattern': + self.arpeggiator.stop() + if self.held_notes: + self.arpeggiator.start() + + @pyqtSlot(bool) + def on_playing_state_changed(self, is_playing: bool): + """Update LEDs based on playing state""" + if is_playing: + # Pulse active pads when playing + self.pulse_active_pads() + else: + # Update static display + self.update_note_leds() + + @pyqtSlot(int, int, int, float) + def on_note_triggered(self, channel: int, note: int, velocity: int, duration: float): + """Provide visual feedback when notes are triggered""" + # Flash corresponding pad if the note maps to one + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger' and mapping.get('note') == note: + brightness = velocity / 127.0 + # Calculate color based on channel + hue = (channel - 1) / 16.0 + self.maschine.set_pad_color_brightness(pad, brightness, hue) + break + + def update_all_leds(self): + """Update all LED displays""" + self.update_note_leds() + self.update_channel_leds() + self.update_pattern_leds() + + def update_note_leds(self): + """Update pad LEDs based on held notes""" + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger': + note = mapping.get('note') + if note in self.held_notes: + self.maschine.set_pad_led(pad, 100, "green") + else: + self.maschine.set_pad_led(pad, 20, "white") + + def update_channel_leds(self): + """Update LEDs based on active channels""" + active_count = self.channel_manager.active_synth_count + + # Use first N pads to indicate active channels + for i in range(1, 17): + if i <= active_count: + # Get channel status + voices = self.channel_manager.get_voice_count(i) + if voices > 0: + brightness = min(100 + voices * 20, 127) # Brighter with more voices + self.maschine.set_pad_led(i, brightness, "blue") + else: + self.maschine.set_pad_led(i, 30, "blue") + else: + self.maschine.set_pad_led(i, 0) + + def update_pattern_leds(self): + """Update LEDs based on current patterns""" + # This could use different pad modes or encoder LEDs + pass + + def pulse_active_pads(self): + """Pulse pads that correspond to held notes""" + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger': + note = mapping.get('note') + if note in self.held_notes: + self.maschine.flash_pad(pad, "yellow", 100) + + def all_notes_off(self): + """Stop all notes and clear held notes""" + self.held_notes.clear() + for note in range(128): + self.arpeggiator.note_off(note) + self.update_note_leds() + + def set_pad_mode(self, mode: str): + """Set pad display mode (notes, channels, patterns, etc.)""" + if mode == "notes": + self.setup_note_mappings() + elif mode == "channels": + self.setup_channel_mappings() + elif mode == "patterns": + self.setup_pattern_mappings() + + self.update_all_leds() + + def setup_note_mappings(self): + """Setup pads for note triggering""" + for i in range(1, 17): + self.maschine.set_pad_mapping(i, { + 'type': 'note_trigger', + 'note': 48 + i, # C3 and up + 'function': None + }) + + def setup_channel_mappings(self): + """Setup pads for channel control""" + for i in range(1, 17): + self.maschine.set_pad_mapping(i, { + 'type': 'channel_select', + 'channel': i, + 'function': None + }) + + def setup_pattern_mappings(self): + """Setup pads for pattern selection""" + patterns = self.arpeggiator.PATTERN_TYPES + for i, pattern in enumerate(patterns[:16], 1): + self.maschine.set_pad_mapping(i, { + 'type': 'pattern_select', + 'pattern': pattern, + 'function': None + }) + + def is_connected(self) -> bool: + """Check if Maschine is connected""" + return self.maschine.is_connected() + + def get_status(self) -> dict: + """Get comprehensive status""" + return { + 'connected': self.is_connected(), + 'held_notes': list(self.held_notes), + 'maschine_status': self.maschine.get_status() + } \ No newline at end of file diff --git a/maschine/maschine_interface.py b/maschine/maschine_interface.py new file mode 100644 index 0000000..60740b6 --- /dev/null +++ b/maschine/maschine_interface.py @@ -0,0 +1,379 @@ +""" +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() \ No newline at end of file diff --git a/presets/butt.json b/presets/butt.json new file mode 100644 index 0000000..653a540 --- /dev/null +++ b/presets/butt.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "timestamp": "2025-09-07T23:11:06.343054", + "arpeggiator": { + "root_note": 50, + "scale": "pentatonic_major", + "pattern_type": "up", + "octave_range": 1, + "note_speed": "1/2", + "gate": 1.0, + "swing": 0.0, + "velocity": 80, + "tempo": 120.0 + }, + "channels": { + "active_synth_count": 3, + "channel_instruments": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "13": 0, + "14": 0, + "15": 0, + "16": 0 + } + }, + "volume_patterns": { + "current_pattern": "swell", + "pattern_speed": 2.0, + "pattern_intensity": 1.0, + "global_volume_range": [ + 0.2, + 1.0 + ], + "global_velocity_range": [ + 40, + 127 + ], + "channel_volume_ranges": {}, + "velocity_ranges": {} + } +} \ No newline at end of file diff --git a/presets/butt2.json b/presets/butt2.json new file mode 100644 index 0000000..63aeb69 --- /dev/null +++ b/presets/butt2.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "timestamp": "2025-09-08T11:17:34.584516", + "arpeggiator": { + "root_note": 60, + "scale": "major", + "pattern_type": "up", + "octave_range": 1, + "note_speed": "1/1", + "gate": 0.78, + "swing": 0.0, + "velocity": 100, + "tempo": 66.0 + }, + "channels": { + "active_synth_count": 8, + "channel_instruments": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "13": 0, + "14": 0, + "15": 0, + "16": 0 + } + }, + "volume_patterns": { + "current_pattern": "random_sparkle", + "pattern_speed": 0.5, + "pattern_intensity": 1.0, + "global_volume_range": [ + 0.0, + 1.0 + ], + "global_velocity_range": [ + 126, + 127 + ], + "channel_volume_ranges": {}, + "velocity_ranges": {} + } +} \ No newline at end of file diff --git a/requirements-windows.txt b/requirements-windows.txt new file mode 100644 index 0000000..8e3dcac --- /dev/null +++ b/requirements-windows.txt @@ -0,0 +1,9 @@ +# Windows-specific requirements with pre-compiled packages +PyQt5>=5.15.0 +mido>=1.2.10 +numpy>=1.21.0 +pygame>=2.1.0 + +# Alternative MIDI libraries for Windows (try these if python-rtmidi fails) +# rtmidi-python>=1.1.0 +# pygame-midi # Built into pygame on Windows \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f60bcf0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt5>=5.15.0 +python-rtmidi>=1.5.0 +mido>=1.2.10 +numpy>=1.21.0 +pygame>=2.1.0 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..ab20d9c --- /dev/null +++ b/run.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +""" +Simple startup script for the MIDI Arpeggiator application. +""" + +import sys +import os + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import and run the main application +from main import ArpeggiatorApp + +if __name__ == "__main__": + try: + app = ArpeggiatorApp() + sys.exit(app.run()) + except KeyboardInterrupt: + print("\nApplication interrupted by user") + sys.exit(0) + except Exception as e: + print(f"Fatal error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/simple_audio_test.py b/simple_audio_test.py new file mode 100644 index 0000000..1373cf8 --- /dev/null +++ b/simple_audio_test.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Simple audio and MIDI test without Unicode issues +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_basic_audio(): + """Test basic pygame audio""" + print("=== Testing Basic Audio ===") + + try: + import pygame + import numpy as np + + pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=512) + pygame.mixer.init() + print("PASS: Pygame mixer initialized") + + # Generate test tone + sample_rate = 22050 + duration = 1.0 + frequency = 440 + + samples = np.sin(2 * np.pi * frequency * np.linspace(0, duration, int(sample_rate * duration))) + samples = (samples * 0.3 * 32767).astype(np.int16) + stereo_samples = np.column_stack((samples, samples)) + + sound = pygame.sndarray.make_sound(stereo_samples) + print("Playing test tone...") + sound.play() + time.sleep(1.2) + print("PASS: Basic audio working") + return True + + except Exception as e: + print(f"FAIL: Basic audio error: {e}") + return False + +def test_simulator(): + """Test simulator engine""" + print("\n=== Testing Simulator ===") + + try: + from simulator.simulator_engine import SimulatorEngine + + simulator = SimulatorEngine() + print(f"Simulator audio enabled: {simulator.audio_enabled}") + print(f"Audio initialized: {simulator.audio_initialized_flag}") + + print("Testing simulator note...") + simulator.play_note(1, 60, 80) + time.sleep(1) + simulator.stop_note(1, 60) + print("PASS: Simulator test completed") + return True + + except Exception as e: + print(f"FAIL: Simulator error: {e}") + return False + +def test_midi(): + """Test MIDI devices""" + print("\n=== Testing MIDI ===") + + try: + import mido + + outputs = mido.get_output_names() + print(f"Found {len(outputs)} MIDI outputs:") + for i, output in enumerate(outputs): + print(f" {i+1}: {output}") + + if outputs: + print(f"Testing with: {outputs[0]}") + midi_out = mido.open_output(outputs[0]) + + # Send test note + msg_on = mido.Message('note_on', channel=0, note=60, velocity=80) + msg_off = mido.Message('note_off', channel=0, note=60, velocity=0) + + midi_out.send(msg_on) + print("Sent note ON") + time.sleep(0.5) + midi_out.send(msg_off) + print("Sent note OFF") + + midi_out.close() + print("PASS: MIDI test completed") + return True + else: + print("FAIL: No MIDI outputs found") + return False + + except Exception as e: + print(f"FAIL: MIDI error: {e}") + return False + +def test_arpeggiator_notes(): + """Test if arpeggiator actually triggers notes""" + print("\n=== Testing Arpeggiator Note Triggering ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create system + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + print("Components created") + + # Set to simulator mode + output_manager.set_mode("simulator") + print(f"Output mode: {output_manager.current_mode}") + + # Configure + arpeggiator.set_root_note(60) + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(240) # Fast tempo for quick test + + # Add notes + print("Adding notes C and E...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + print(f"Held notes: {len(arpeggiator.held_notes)}") + print(f"Pattern length: {arpeggiator.pattern_length}") + + # Start arpeggiator + print("Starting arpeggiator...") + started = arpeggiator.start() + print(f"Started: {started}") + + if started: + print("Running for 3 seconds...") + time.sleep(3) + arpeggiator.stop() + print("Stopped") + return True + else: + print("FAIL: Could not start arpeggiator") + return False + + except Exception as e: + print(f"FAIL: Arpeggiator error: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + print("Simple Audio/MIDI Diagnostic") + print("=" * 40) + + # Test basic audio first + audio_ok = test_basic_audio() + + # Test simulator + sim_ok = test_simulator() + + # Test MIDI + midi_ok = test_midi() + + # Test full arpeggiator + arp_ok = test_arpeggiator_notes() + + print("\n" + "=" * 40) + print("RESULTS:") + print(f"Basic Audio: {'PASS' if audio_ok else 'FAIL'}") + print(f"Simulator: {'PASS' if sim_ok else 'FAIL'}") + print(f"MIDI: {'PASS' if midi_ok else 'FAIL'}") + print(f"Arpeggiator: {'PASS' if arp_ok else 'FAIL'}") + + if not audio_ok: + print("\nAudio issues: Check Windows audio settings") + if not midi_ok: + print("\nMIDI issues: Install virtual MIDI cable (loopMIDI)") + if not arp_ok: + print("\nArpeggiator issues: Check component integration") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/simulator/__init__.py b/simulator/__init__.py new file mode 100644 index 0000000..f1697ea --- /dev/null +++ b/simulator/__init__.py @@ -0,0 +1 @@ +# Simulator module - Internal audio synthesis and visual lighting simulation \ No newline at end of file diff --git a/simulator/__pycache__/__init__.cpython-310.pyc b/simulator/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..7e6e144 Binary files /dev/null and b/simulator/__pycache__/__init__.cpython-310.pyc differ diff --git a/simulator/__pycache__/simulator_engine.cpython-310.pyc b/simulator/__pycache__/simulator_engine.cpython-310.pyc new file mode 100644 index 0000000..c15074a Binary files /dev/null and b/simulator/__pycache__/simulator_engine.cpython-310.pyc differ diff --git a/simulator/simulator_engine.py b/simulator/simulator_engine.py new file mode 100644 index 0000000..89613eb --- /dev/null +++ b/simulator/simulator_engine.py @@ -0,0 +1,390 @@ +""" +Simulator Engine Module + +Internal audio synthesis and visual lighting simulation. +Provides audio feedback and visual representation of the lighting installation. +""" + +import pygame +import numpy as np +import threading +import time +import math +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +class SimulatorEngine(QObject): + """ + Internal simulator for audio synthesis and lighting visualization. + Provides real-time audio feedback and visual representation. + """ + + # Signals + audio_initialized = pyqtSignal(bool) + note_played = pyqtSignal(int, int, int) # channel, note, velocity + note_stopped = pyqtSignal(int, int) # channel, note + program_changed = pyqtSignal(int, int) # channel, program + volume_changed = pyqtSignal(int, int) # channel, volume + lighting_updated = pyqtSignal(dict) # {channel: brightness} + + # Waveform types + WAVEFORMS = ["sine", "square", "sawtooth", "triangle", "noise"] + + # GM Instrument to waveform mapping (simplified) + GM_WAVEFORM_MAP = { + # Pianos (0-7) + 0: "sine", 1: "sine", 2: "sine", 3: "square", + 4: "sine", 5: "sine", 6: "square", 7: "square", + + # Chromatic Percussion (8-15) + 8: "sine", 9: "sine", 10: "sine", 11: "sine", + 12: "sine", 13: "sine", 14: "sine", 15: "triangle", + + # Organs (16-23) + 16: "square", 17: "square", 18: "square", 19: "square", + 20: "square", 21: "square", 22: "triangle", 23: "square", + + # Guitars (24-31) + 24: "sawtooth", 25: "sawtooth", 26: "sawtooth", 27: "sawtooth", + 28: "square", 29: "sawtooth", 30: "sawtooth", 31: "sine", + + # Bass (32-39) + 32: "sawtooth", 33: "sawtooth", 34: "sawtooth", 35: "sawtooth", + 36: "sawtooth", 37: "sawtooth", 38: "sawtooth", 39: "sawtooth", + + # Strings (40-47) + 40: "sawtooth", 41: "sawtooth", 42: "sawtooth", 43: "sawtooth", + 44: "sawtooth", 45: "sawtooth", 46: "sine", 47: "sine", + + # Ensemble (48-55) + 48: "sawtooth", 49: "sawtooth", 50: "sawtooth", 51: "sawtooth", + 52: "sine", 53: "sine", 54: "sawtooth", 55: "sine", + + # Brass (56-63) + 56: "sawtooth", 57: "sawtooth", 58: "sawtooth", 59: "sawtooth", + 60: "sawtooth", 61: "sawtooth", 62: "sawtooth", 63: "sawtooth", + + # Reed (64-71) + 64: "sawtooth", 65: "sawtooth", 66: "sawtooth", 67: "sawtooth", + 68: "sine", 69: "sine", 70: "sine", 71: "sine", + + # Pipe (72-79) + 72: "sine", 73: "sine", 74: "sine", 75: "sine", + 76: "sine", 77: "sine", 78: "sine", 79: "sine", + + # Synth Lead (80-87) + 80: "square", 81: "sawtooth", 82: "square", 83: "square", + 84: "sawtooth", 85: "square", 86: "sawtooth", 87: "sawtooth", + + # Synth Pad (88-95) + 88: "sawtooth", 89: "sawtooth", 90: "sawtooth", 91: "sine", + 92: "sawtooth", 93: "sawtooth", 94: "sine", 95: "sawtooth", + + # Synth Effects (96-103) + 96: "noise", 97: "sawtooth", 98: "sine", 99: "sawtooth", + 100: "sawtooth", 101: "noise", 102: "sine", 103: "sawtooth", + + # Ethnic (104-111) + 104: "sawtooth", 105: "sawtooth", 106: "sawtooth", 107: "sine", + 108: "sine", 109: "square", 110: "sawtooth", 111: "sine", + + # Percussive (112-119) + 112: "noise", 113: "noise", 114: "sawtooth", 115: "noise", + 116: "noise", 117: "noise", 118: "noise", 119: "noise", + + # Sound Effects (120-127) + 120: "noise", 121: "noise", 122: "noise", 123: "noise", + 124: "noise", 125: "noise", 126: "noise", 127: "noise" + } + + def __init__(self): + super().__init__() + + # Audio settings + self.sample_rate = 22050 + self.buffer_size = 512 + self.audio_enabled = True + self.master_volume = 0.8 + self.stereo_mode = False # Will be set during audio initialization + + # Channel settings + self.channel_volumes = {i: 100 for i in range(1, 17)} # MIDI volume (0-127) + self.channel_programs = {i: 0 for i in range(1, 17)} # GM program numbers + + # Active voices {(channel, note): voice_data} + self.active_voices: Dict[Tuple[int, int], Dict] = {} + + # Lighting state {channel: brightness (0.0-1.0)} + self.lighting_state = {i: 0.0 for i in range(1, 17)} + + # Audio system + self.audio_initialized_flag = False + self.audio_thread = None + self.audio_running = False + + # Initialize audio + self.initialize_audio() + + def initialize_audio(self): + """Initialize pygame audio system""" + try: + # Try stereo first (most common) + try: + pygame.mixer.pre_init( + frequency=self.sample_rate, + size=-16, + channels=2, # Stereo + buffer=self.buffer_size + ) + pygame.mixer.init() + self.stereo_mode = True + except: + # Fall back to mono if stereo fails + pygame.mixer.pre_init( + frequency=self.sample_rate, + size=-16, + channels=1, # Mono + buffer=self.buffer_size + ) + pygame.mixer.init() + self.stereo_mode = False + + self.audio_initialized_flag = True + self.audio_initialized.emit(True) + print(f"Audio initialized: {self.sample_rate}Hz, {'Stereo' if self.stereo_mode else 'Mono'}") + + except Exception as e: + print(f"Audio initialization failed: {e}") + self.audio_initialized_flag = False + self.stereo_mode = False + self.audio_initialized.emit(False) + + def set_audio_enabled(self, enabled: bool): + """Enable or disable audio output""" + self.audio_enabled = enabled + + def set_master_volume(self, volume: float): + """Set master volume (0.0 to 1.0)""" + self.master_volume = max(0.0, min(1.0, volume)) + + def play_note(self, channel: int, note: int, velocity: int): + """Play a note on the specified channel""" + if not (1 <= channel <= 16 and 0 <= note <= 127 and 0 <= velocity <= 127): + return + + # Stop any existing note on this channel/note combination + self.stop_note(channel, note) + + # Get program for channel + program = self.channel_programs.get(channel, 0) + waveform = self.GM_WAVEFORM_MAP.get(program, "sine") + + # Calculate frequency + frequency = self.midi_note_to_frequency(note) + + # Calculate volume + channel_volume = self.channel_volumes.get(channel, 100) / 127.0 + velocity_volume = velocity / 127.0 + final_volume = channel_volume * velocity_volume * self.master_volume + + # Create voice data + voice_data = { + 'frequency': frequency, + 'waveform': waveform, + 'volume': final_volume, + 'phase': 0.0, + 'start_time': time.time(), + 'velocity': velocity + } + + # Store active voice + self.active_voices[(channel, note)] = voice_data + + # Update lighting + self.update_lighting(channel, velocity, channel_volume) + + # Play audio if enabled + if self.audio_enabled and self.audio_initialized_flag: + self.play_audio_note(frequency, waveform, final_volume) + + # Emit signal + self.note_played.emit(channel, note, velocity) + + def stop_note(self, channel: int, note: int): + """Stop a note on the specified channel""" + voice_key = (channel, note) + if voice_key in self.active_voices: + del self.active_voices[voice_key] + + # Update lighting (fade out) + self.fade_lighting(channel) + + self.note_stopped.emit(channel, note) + + def change_program(self, channel: int, program: int): + """Change the program (instrument) for a channel""" + if 1 <= channel <= 16 and 0 <= program <= 127: + self.channel_programs[channel] = program + self.program_changed.emit(channel, program) + + def set_channel_volume(self, channel: int, volume: int): + """Set channel volume (0-127)""" + if 1 <= channel <= 16 and 0 <= volume <= 127: + self.channel_volumes[channel] = volume + + # Update active voices volume + for (ch, note), voice_data in self.active_voices.items(): + if ch == channel: + channel_volume = volume / 127.0 + velocity_volume = voice_data['velocity'] / 127.0 + voice_data['volume'] = channel_volume * velocity_volume * self.master_volume + + self.volume_changed.emit(channel, volume) + + def all_notes_off(self, channel: int = None): + """Turn off all notes on a channel (or all channels if None)""" + if channel: + # Turn off notes for specific channel + to_remove = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] + else: + # Turn off all notes + to_remove = list(self.active_voices.keys()) + + for voice_key in to_remove: + ch, note = voice_key + del self.active_voices[voice_key] + self.note_stopped.emit(ch, note) + + # Update lighting + if channel: + self.lighting_state[channel] = 0.0 + else: + for ch in range(1, 17): + self.lighting_state[ch] = 0.0 + + self.lighting_updated.emit(self.lighting_state.copy()) + + def panic(self): + """Emergency stop - turn off everything""" + self.all_notes_off() + + def update_lighting(self, channel: int, velocity: int, channel_volume: float): + """Update lighting state based on note activity""" + # Calculate brightness from velocity and volume + velocity_brightness = velocity / 127.0 + brightness = velocity_brightness * channel_volume + + # Set lighting state + self.lighting_state[channel] = brightness + self.lighting_updated.emit(self.lighting_state.copy()) + + def fade_lighting(self, channel: int): + """Gradually fade lighting for a channel""" + current_brightness = self.lighting_state.get(channel, 0.0) + + # Check if there are still active notes on this channel + active_notes_on_channel = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] + + if not active_notes_on_channel: + # No active notes, fade to zero + self.lighting_state[channel] = 0.0 + else: + # Calculate brightness from remaining active notes + total_brightness = 0.0 + for (ch, note) in active_notes_on_channel: + voice_data = self.active_voices[(ch, note)] + total_brightness = max(total_brightness, voice_data['volume']) + + self.lighting_state[channel] = total_brightness + + self.lighting_updated.emit(self.lighting_state.copy()) + + def update_lighting_display(self): + """Update lighting display (called regularly from main loop)""" + # This method can be used for lighting animations or effects + # For now, just emit current state + self.lighting_updated.emit(self.lighting_state.copy()) + + def play_audio_note(self, frequency: float, waveform: str, volume: float): + """Generate and play audio for a note""" + if not self.audio_initialized_flag: + return + + try: + # Generate a short tone + duration = 0.5 # seconds + sample_count = int(self.sample_rate * duration) + + # Generate waveform + if waveform == "sine": + samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) + elif waveform == "square": + samples = np.sign(np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate)) + elif waveform == "sawtooth": + samples = 2 * (np.arange(sample_count) * frequency / self.sample_rate % 1) - 1 + elif waveform == "triangle": + t = np.arange(sample_count) * frequency / self.sample_rate % 1 + samples = 2 * np.abs(2 * t - 1) - 1 + elif waveform == "noise": + samples = np.random.uniform(-1, 1, sample_count) + else: + samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) + + # Apply volume and envelope + envelope = np.exp(-np.arange(sample_count) / (self.sample_rate * 0.3)) # Decay envelope + samples = samples * envelope * volume * 32767 + + # Convert to 16-bit integers and ensure proper shape + samples = samples.astype(np.int16) + + # Create appropriate array format based on mixer initialization + if self.stereo_mode: + # Create stereo samples (duplicate mono to both channels) + stereo_samples = np.column_stack((samples, samples)) + sound = pygame.sndarray.make_sound(stereo_samples) + else: + # Use mono samples directly + sound = pygame.sndarray.make_sound(samples) + + sound.play() + + except Exception as e: + print(f"Error playing audio note: {e}") + + @staticmethod + def midi_note_to_frequency(note: int) -> float: + """Convert MIDI note number to frequency in Hz""" + # A4 (note 69) = 440 Hz + return 440.0 * (2.0 ** ((note - 69) / 12.0)) + + def get_active_voices_count(self, channel: int = None) -> int: + """Get count of active voices""" + if channel: + return len([(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel]) + else: + return len(self.active_voices) + + def get_lighting_state(self) -> Dict[int, float]: + """Get current lighting state""" + return self.lighting_state.copy() + + def get_status(self) -> Dict: + """Get simulator status""" + return { + 'audio_initialized': self.audio_initialized_flag, + 'audio_enabled': self.audio_enabled, + 'master_volume': self.master_volume, + 'active_voices': len(self.active_voices), + 'channel_volumes': self.channel_volumes.copy(), + 'channel_programs': self.channel_programs.copy(), + 'lighting_state': self.lighting_state.copy() + } + + def cleanup(self): + """Clean up resources""" + self.all_notes_off() + if self.audio_initialized_flag: + try: + pygame.mixer.quit() + except: + pass \ No newline at end of file diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..8f035c0 --- /dev/null +++ b/test_app.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Test script to verify the application components work without launching GUI. +""" + +import sys +import os + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_core_modules(): + """Test core application modules""" + print("Testing core modules...") + + try: + from core.midi_channel_manager import MIDIChannelManager + channel_manager = MIDIChannelManager() + print(" - MIDI Channel Manager: OK") + + from core.volume_pattern_engine import VolumePatternEngine + volume_engine = VolumePatternEngine() + print(" - Volume Pattern Engine: OK") + + from core.synth_router import SynthRouter + synth_router = SynthRouter(channel_manager) + print(" - Synth Router: OK") + + from simulator.simulator_engine import SimulatorEngine + simulator = SimulatorEngine() + print(" - Simulator Engine: OK") + + from core.output_manager import OutputManager + output_manager = OutputManager(simulator) + print(" - Output Manager: OK") + + from core.arpeggiator_engine import ArpeggiatorEngine + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + print(" - Arpeggiator Engine: OK") + + return True, (channel_manager, volume_engine, synth_router, simulator, output_manager, arpeggiator) + + except Exception as e: + print(f" - Error: {e}") + import traceback + traceback.print_exc() + return False, None + +def test_functionality(components): + """Test basic functionality""" + print("\nTesting functionality...") + + channel_manager, volume_engine, synth_router, simulator, output_manager, arpeggiator = components + + try: + # Test channel manager + channel_manager.set_active_synth_count(4) + channel_manager.set_channel_instrument(1, 10) # Drum kit + print(" - Channel management: OK") + + # Test volume patterns + volume_engine.set_pattern("swell") + volume_engine.set_pattern_speed(1.5) + volumes = volume_engine.get_all_channel_volumes(4) + print(f" - Volume patterns: OK (generated {len(volumes)} channel volumes)") + + # Test arpeggiator + arpeggiator.set_root_note(60) # Middle C + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + print(" - Arpeggiator settings: OK") + + # Test note handling (without starting playback) + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + arpeggiator.note_on(67) # G + print(f" - Note input: OK (holding {len(arpeggiator.held_notes)} notes)") + + # Test simulator + simulator.play_note(1, 60, 80) + simulator.stop_note(1, 60) + print(" - Simulator playback: OK") + + return True + + except Exception as e: + print(f" - Functionality error: {e}") + import traceback + traceback.print_exc() + return False + +def test_maschine_integration(): + """Test Maschine integration (without actual hardware)""" + print("\nTesting Maschine integration...") + + try: + from maschine.maschine_interface import MaschineInterface + from maschine.maschine_controller import MaschineController + + # Note: This won't actually connect without hardware + maschine_interface = MaschineInterface() + print(" - Maschine Interface: OK") + + # Test finding devices (will return empty list without hardware) + inputs, outputs = maschine_interface.find_maschine_devices() + print(f" - Device detection: OK (found {len(inputs)} inputs, {len(outputs)} outputs)") + + return True + + except Exception as e: + print(f" - Maschine integration error: {e}") + return False + +def main(): + """Run all tests""" + print("MIDI Arpeggiator - Application Test") + print("=" * 40) + + # Test core modules + success, components = test_core_modules() + if not success: + print("\nCore module test failed!") + return 1 + + # Test functionality + if not test_functionality(components): + print("\nFunctionality test failed!") + return 1 + + # Test Maschine integration + test_maschine_integration() + + print("\n" + "=" * 40) + print("All tests completed successfully!") + print("\nYour application is ready to run!") + print("Execute: python run.py") + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test_hardware_midi.py b/test_hardware_midi.py new file mode 100644 index 0000000..0241d11 --- /dev/null +++ b/test_hardware_midi.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Test hardware MIDI output with the output manager +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_output_manager_hardware(): + """Test the output manager in hardware mode""" + print("=== Testing Output Manager Hardware Mode ===") + + try: + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + + # Create output manager + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + + print(f"Available MIDI outputs: {output_manager.get_available_outputs()}") + + # Switch to hardware mode + success = output_manager.set_mode("hardware") + print(f"Switched to hardware mode: {success}") + print(f"Current mode: {output_manager.current_mode}") + + # Try to connect to first available device + outputs = output_manager.get_available_outputs() + if outputs and "No MIDI" not in outputs[0]: + device_name = outputs[0] + print(f"Connecting to: {device_name}") + + success = output_manager.set_midi_output(device_name) + print(f"Connected: {success}") + + if success: + print("Sending test notes...") + + # Send a sequence of notes + for note in [60, 64, 67, 72]: # C major chord + octave + print(f"Sending note {note}") + output_manager.send_note_on(1, note, 80) + time.sleep(0.3) + output_manager.send_note_off(1, note) + time.sleep(0.1) + + print("Hardware MIDI test completed") + return True + else: + print("No suitable MIDI outputs found") + return False + + except Exception as e: + print(f"Hardware MIDI test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_full_arpeggiator_hardware(): + """Test full arpeggiator in hardware mode""" + print("\n=== Testing Full Arpeggiator in Hardware Mode ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create system + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + # Switch to hardware mode + output_manager.set_mode("hardware") + + # Connect to MIDI device + outputs = output_manager.get_available_outputs() + if outputs and "No MIDI" not in outputs[0]: + output_manager.set_midi_output(outputs[0]) + print(f"Connected to: {outputs[0]}") + else: + print("No MIDI device available") + return False + + # Configure arpeggiator + arpeggiator.set_root_note(60) + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + + # Add notes and start + print("Adding notes and starting arpeggiator...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + started = arpeggiator.start() + print(f"Arpeggiator started: {started}") + + if started: + print("Running arpeggiator for 5 seconds...") + time.sleep(5) + arpeggiator.stop() + print("Stopped") + return True + else: + return False + + except Exception as e: + print(f"Full hardware test failed: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + print("Hardware MIDI Test") + print("=" * 30) + + # Test output manager + om_ok = test_output_manager_hardware() + + # Test full arpeggiator + full_ok = test_full_arpeggiator_hardware() + + print("\n" + "=" * 30) + print("RESULTS:") + print(f"Output Manager: {'PASS' if om_ok else 'FAIL'}") + print(f"Full System: {'PASS' if full_ok else 'FAIL'}") + + if not om_ok: + print("\nOutput Manager issues - check MIDI device selection") + if not full_ok: + print("\nFull system issues - check arpeggiator integration") + +if __name__ == "__main__": + main() \ No newline at end of file