Browse Source

first commit

master
melancholytron 2 months ago
commit
bacc0059c5
  1. 11
      .claude/settings.local.json
  2. 30
      CLAUDE.md
  3. 103
      INSTALL_WINDOWS.md
  4. 157
      README.md
  5. 160
      USAGE_GUIDE.md
  6. 1
      __init__.py
  7. BIN
      __pycache__/main.cpython-310.pyc
  8. 1
      config/__init__.py
  9. BIN
      config/__pycache__/__init__.cpython-310.pyc
  10. BIN
      config/__pycache__/configuration.cpython-310.pyc
  11. 335
      config/configuration.py
  12. 1
      core/__init__.py
  13. BIN
      core/__pycache__/__init__.cpython-310.pyc
  14. BIN
      core/__pycache__/arpeggiator_engine.cpython-310.pyc
  15. BIN
      core/__pycache__/midi_channel_manager.cpython-310.pyc
  16. BIN
      core/__pycache__/output_manager.cpython-310.pyc
  17. BIN
      core/__pycache__/synth_router.cpython-310.pyc
  18. BIN
      core/__pycache__/volume_pattern_engine.cpython-310.pyc
  19. 630
      core/arpeggiator_engine.py
  20. 183
      core/midi_channel_manager.py
  21. 336
      core/output_manager.py
  22. 352
      core/synth_router.py
  23. 280
      core/volume_pattern_engine.py
  24. 268
      diagnose_audio_midi.py
  25. 1
      fallback/__init__.py
  26. 92
      fallback/rtmidi_fallback.py
  27. 1
      gui/__init__.py
  28. BIN
      gui/__pycache__/__init__.cpython-310.pyc
  29. BIN
      gui/__pycache__/arpeggiator_controls.cpython-310.pyc
  30. BIN
      gui/__pycache__/channel_controls.cpython-310.pyc
  31. BIN
      gui/__pycache__/main_window.cpython-310.pyc
  32. BIN
      gui/__pycache__/output_controls.cpython-310.pyc
  33. BIN
      gui/__pycache__/preset_controls.cpython-310.pyc
  34. BIN
      gui/__pycache__/simulator_display.cpython-310.pyc
  35. BIN
      gui/__pycache__/volume_controls.cpython-310.pyc
  36. 601
      gui/arpeggiator_controls.py
  37. 964
      gui/arpeggiator_controls_backup.py
  38. 691
      gui/arpeggiator_controls_new.py
  39. 233
      gui/channel_controls.py
  40. 628
      gui/main_window.py
  41. 268
      gui/output_controls.py
  42. 510
      gui/preset_controls.py
  43. 173
      gui/simulator_display.py
  44. 317
      gui/volume_controls.py
  45. 196
      install_windows.py
  46. 114
      main.py
  47. 1
      maschine/__init__.py
  48. BIN
      maschine/__pycache__/__init__.cpython-310.pyc
  49. BIN
      maschine/__pycache__/maschine_controller.cpython-310.pyc
  50. BIN
      maschine/__pycache__/maschine_interface.cpython-310.pyc
  51. 332
      maschine/maschine_controller.py
  52. 379
      maschine/maschine_interface.py
  53. 51
      presets/butt.json
  54. 51
      presets/butt2.json
  55. 9
      requirements-windows.txt
  56. 5
      requirements.txt
  57. 24
      run.py
  58. 196
      simple_audio_test.py
  59. 1
      simulator/__init__.py
  60. BIN
      simulator/__pycache__/__init__.cpython-310.pyc
  61. BIN
      simulator/__pycache__/simulator_engine.cpython-310.pyc
  62. 390
      simulator/simulator_engine.py
  63. 142
      test_app.py
  64. 146
      test_hardware_midi.py

11
.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": []
}
}

30
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

103
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.

157
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.

160
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!** 🌟

1
__init__.py

@ -0,0 +1 @@
# Empty file to make this directory a Python package

BIN
__pycache__/main.cpython-310.pyc

1
config/__init__.py

@ -0,0 +1 @@
# Configuration module - Application settings and configuration management

BIN
config/__pycache__/__init__.cpython-310.pyc

BIN
config/__pycache__/configuration.cpython-310.pyc

335
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())
}

1
core/__init__.py

@ -0,0 +1 @@
# Core module - Contains the main arpeggiator logic and MIDI handling

BIN
core/__pycache__/__init__.cpython-310.pyc

BIN
core/__pycache__/arpeggiator_engine.cpython-310.pyc

BIN
core/__pycache__/midi_channel_manager.cpython-310.pyc

BIN
core/__pycache__/output_manager.cpython-310.pyc

BIN
core/__pycache__/synth_router.cpython-310.pyc

BIN
core/__pycache__/volume_pattern_engine.cpython-310.pyc

630
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()
}

183
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

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

352
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()
}

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

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

1
fallback/__init__.py

@ -0,0 +1 @@
# Fallback MIDI implementations

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

1
gui/__init__.py

@ -0,0 +1 @@
# GUI module - PyQt5 user interface components

BIN
gui/__pycache__/__init__.cpython-310.pyc

BIN
gui/__pycache__/arpeggiator_controls.cpython-310.pyc

BIN
gui/__pycache__/channel_controls.cpython-310.pyc

BIN
gui/__pycache__/main_window.cpython-310.pyc

BIN
gui/__pycache__/output_controls.cpython-310.pyc

BIN
gui/__pycache__/preset_controls.cpython-310.pyc

BIN
gui/__pycache__/simulator_display.cpython-310.pyc

BIN
gui/__pycache__/volume_controls.cpython-310.pyc

601
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

964
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}")

691
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}")

233
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)

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

268
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)

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

173
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)

317
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)

196
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.")

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

1
maschine/__init__.py

@ -0,0 +1 @@
# Maschine integration module - Native Instruments Maschine controller interface

BIN
maschine/__pycache__/__init__.cpython-310.pyc

BIN
maschine/__pycache__/maschine_controller.cpython-310.pyc

BIN
maschine/__pycache__/maschine_interface.cpython-310.pyc

332
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()
}

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

51
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": {}
}
}

51
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": {}
}
}

9
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

5
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

24
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)

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

1
simulator/__init__.py

@ -0,0 +1 @@
# Simulator module - Internal audio synthesis and visual lighting simulation

BIN
simulator/__pycache__/__init__.cpython-310.pyc

BIN
simulator/__pycache__/simulator_engine.cpython-310.pyc

390
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

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

146
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()
Loading…
Cancel
Save