commit
bacc0059c5
64 changed files with 9364 additions and 0 deletions
-
11.claude/settings.local.json
-
30CLAUDE.md
-
103INSTALL_WINDOWS.md
-
157README.md
-
160USAGE_GUIDE.md
-
1__init__.py
-
BIN__pycache__/main.cpython-310.pyc
-
1config/__init__.py
-
BINconfig/__pycache__/__init__.cpython-310.pyc
-
BINconfig/__pycache__/configuration.cpython-310.pyc
-
335config/configuration.py
-
1core/__init__.py
-
BINcore/__pycache__/__init__.cpython-310.pyc
-
BINcore/__pycache__/arpeggiator_engine.cpython-310.pyc
-
BINcore/__pycache__/midi_channel_manager.cpython-310.pyc
-
BINcore/__pycache__/output_manager.cpython-310.pyc
-
BINcore/__pycache__/synth_router.cpython-310.pyc
-
BINcore/__pycache__/volume_pattern_engine.cpython-310.pyc
-
630core/arpeggiator_engine.py
-
183core/midi_channel_manager.py
-
336core/output_manager.py
-
352core/synth_router.py
-
280core/volume_pattern_engine.py
-
268diagnose_audio_midi.py
-
1fallback/__init__.py
-
92fallback/rtmidi_fallback.py
-
1gui/__init__.py
-
BINgui/__pycache__/__init__.cpython-310.pyc
-
BINgui/__pycache__/arpeggiator_controls.cpython-310.pyc
-
BINgui/__pycache__/channel_controls.cpython-310.pyc
-
BINgui/__pycache__/main_window.cpython-310.pyc
-
BINgui/__pycache__/output_controls.cpython-310.pyc
-
BINgui/__pycache__/preset_controls.cpython-310.pyc
-
BINgui/__pycache__/simulator_display.cpython-310.pyc
-
BINgui/__pycache__/volume_controls.cpython-310.pyc
-
601gui/arpeggiator_controls.py
-
964gui/arpeggiator_controls_backup.py
-
691gui/arpeggiator_controls_new.py
-
233gui/channel_controls.py
-
628gui/main_window.py
-
268gui/output_controls.py
-
510gui/preset_controls.py
-
173gui/simulator_display.py
-
317gui/volume_controls.py
-
196install_windows.py
-
114main.py
-
1maschine/__init__.py
-
BINmaschine/__pycache__/__init__.cpython-310.pyc
-
BINmaschine/__pycache__/maschine_controller.cpython-310.pyc
-
BINmaschine/__pycache__/maschine_interface.cpython-310.pyc
-
332maschine/maschine_controller.py
-
379maschine/maschine_interface.py
-
51presets/butt.json
-
51presets/butt2.json
-
9requirements-windows.txt
-
5requirements.txt
-
24run.py
-
196simple_audio_test.py
-
1simulator/__init__.py
-
BINsimulator/__pycache__/__init__.cpython-310.pyc
-
BINsimulator/__pycache__/simulator_engine.cpython-310.pyc
-
390simulator/simulator_engine.py
-
142test_app.py
-
146test_hardware_midi.py
@ -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": [] |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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. |
|||
@ -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. |
|||
@ -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!** 🌟 |
|||
@ -0,0 +1 @@ |
|||
# Empty file to make this directory a Python package |
|||
@ -0,0 +1 @@ |
|||
# Configuration module - Application settings and configuration management |
|||
@ -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()) |
|||
} |
|||
@ -0,0 +1 @@ |
|||
# Core module - Contains the main arpeggiator logic and MIDI handling |
|||
@ -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() |
|||
} |
|||
@ -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 |
|||
@ -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() |
|||
@ -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() |
|||
} |
|||
@ -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() |
|||
@ -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() |
|||
@ -0,0 +1 @@ |
|||
# Fallback MIDI implementations |
|||
@ -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") |
|||
@ -0,0 +1 @@ |
|||
# GUI module - PyQt5 user interface components |
|||
@ -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 |
|||
@ -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}") |
|||
@ -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}") |
|||
@ -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) |
|||
@ -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() |
|||
@ -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) |
|||
@ -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() |
|||
@ -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) |
|||
@ -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) |
|||
@ -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.") |
|||
@ -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()) |
|||
@ -0,0 +1 @@ |
|||
# Maschine integration module - Native Instruments Maschine controller interface |
|||
@ -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() |
|||
} |
|||
@ -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() |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -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": {} |
|||
} |
|||
} |
|||
@ -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 |
|||
@ -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 |
|||
@ -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) |
|||
@ -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() |
|||
@ -0,0 +1 @@ |
|||
# Simulator module - Internal audio synthesis and visual lighting simulation |
|||
@ -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 |
|||
@ -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()) |
|||
@ -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() |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue