From bacc0059c510ae8756ef8b878bcb46895217a2a1 Mon Sep 17 00:00:00 2001 From: melancholytron Date: Mon, 8 Sep 2025 12:03:56 -0500 Subject: [PATCH] first commit --- .claude/settings.local.json | 11 + CLAUDE.md | 30 + INSTALL_WINDOWS.md | 103 ++ README.md | 157 +++ USAGE_GUIDE.md | 160 +++ __init__.py | 1 + __pycache__/main.cpython-310.pyc | Bin 0 -> 3011 bytes config/__init__.py | 1 + config/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 130 bytes .../__pycache__/configuration.cpython-310.pyc | Bin 0 -> 10843 bytes config/configuration.py | 335 ++++++ core/__init__.py | 1 + core/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 128 bytes .../arpeggiator_engine.cpython-310.pyc | Bin 0 -> 16822 bytes .../midi_channel_manager.cpython-310.pyc | Bin 0 -> 7573 bytes .../output_manager.cpython-310.pyc | Bin 0 -> 10670 bytes core/__pycache__/synth_router.cpython-310.pyc | Bin 0 -> 9338 bytes .../volume_pattern_engine.cpython-310.pyc | Bin 0 -> 8699 bytes core/arpeggiator_engine.py | 630 ++++++++++++ core/midi_channel_manager.py | 183 ++++ core/output_manager.py | 336 ++++++ core/synth_router.py | 352 +++++++ core/volume_pattern_engine.py | 280 +++++ diagnose_audio_midi.py | 268 +++++ fallback/__init__.py | 1 + fallback/rtmidi_fallback.py | 92 ++ gui/__init__.py | 1 + gui/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 127 bytes .../arpeggiator_controls.cpython-310.pyc | Bin 0 -> 15040 bytes .../channel_controls.cpython-310.pyc | Bin 0 -> 7285 bytes gui/__pycache__/main_window.cpython-310.pyc | Bin 0 -> 18694 bytes .../output_controls.cpython-310.pyc | Bin 0 -> 8092 bytes .../preset_controls.cpython-310.pyc | Bin 0 -> 13754 bytes .../simulator_display.cpython-310.pyc | Bin 0 -> 6083 bytes .../volume_controls.cpython-310.pyc | Bin 0 -> 8522 bytes gui/arpeggiator_controls.py | 601 +++++++++++ gui/arpeggiator_controls_backup.py | 964 ++++++++++++++++++ gui/arpeggiator_controls_new.py | 691 +++++++++++++ gui/channel_controls.py | 233 +++++ gui/main_window.py | 628 ++++++++++++ gui/output_controls.py | 268 +++++ gui/preset_controls.py | 510 +++++++++ gui/simulator_display.py | 173 ++++ gui/volume_controls.py | 317 ++++++ install_windows.py | 196 ++++ main.py | 114 +++ maschine/__init__.py | 1 + maschine/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 132 bytes .../maschine_controller.cpython-310.pyc | Bin 0 -> 10018 bytes .../maschine_interface.cpython-310.pyc | Bin 0 -> 11154 bytes maschine/maschine_controller.py | 332 ++++++ maschine/maschine_interface.py | 379 +++++++ presets/butt.json | 51 + presets/butt2.json | 51 + requirements-windows.txt | 9 + requirements.txt | 5 + run.py | 24 + simple_audio_test.py | 196 ++++ simulator/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 133 bytes .../simulator_engine.cpython-310.pyc | Bin 0 -> 11280 bytes simulator/simulator_engine.py | 390 +++++++ test_app.py | 142 +++ test_hardware_midi.py | 146 +++ 64 files changed, 9364 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 INSTALL_WINDOWS.md create mode 100644 README.md create mode 100644 USAGE_GUIDE.md create mode 100644 __init__.py create mode 100644 __pycache__/main.cpython-310.pyc create mode 100644 config/__init__.py create mode 100644 config/__pycache__/__init__.cpython-310.pyc create mode 100644 config/__pycache__/configuration.cpython-310.pyc create mode 100644 config/configuration.py create mode 100644 core/__init__.py create mode 100644 core/__pycache__/__init__.cpython-310.pyc create mode 100644 core/__pycache__/arpeggiator_engine.cpython-310.pyc create mode 100644 core/__pycache__/midi_channel_manager.cpython-310.pyc create mode 100644 core/__pycache__/output_manager.cpython-310.pyc create mode 100644 core/__pycache__/synth_router.cpython-310.pyc create mode 100644 core/__pycache__/volume_pattern_engine.cpython-310.pyc create mode 100644 core/arpeggiator_engine.py create mode 100644 core/midi_channel_manager.py create mode 100644 core/output_manager.py create mode 100644 core/synth_router.py create mode 100644 core/volume_pattern_engine.py create mode 100644 diagnose_audio_midi.py create mode 100644 fallback/__init__.py create mode 100644 fallback/rtmidi_fallback.py create mode 100644 gui/__init__.py create mode 100644 gui/__pycache__/__init__.cpython-310.pyc create mode 100644 gui/__pycache__/arpeggiator_controls.cpython-310.pyc create mode 100644 gui/__pycache__/channel_controls.cpython-310.pyc create mode 100644 gui/__pycache__/main_window.cpython-310.pyc create mode 100644 gui/__pycache__/output_controls.cpython-310.pyc create mode 100644 gui/__pycache__/preset_controls.cpython-310.pyc create mode 100644 gui/__pycache__/simulator_display.cpython-310.pyc create mode 100644 gui/__pycache__/volume_controls.cpython-310.pyc create mode 100644 gui/arpeggiator_controls.py create mode 100644 gui/arpeggiator_controls_backup.py create mode 100644 gui/arpeggiator_controls_new.py create mode 100644 gui/channel_controls.py create mode 100644 gui/main_window.py create mode 100644 gui/output_controls.py create mode 100644 gui/preset_controls.py create mode 100644 gui/simulator_display.py create mode 100644 gui/volume_controls.py create mode 100644 install_windows.py create mode 100644 main.py create mode 100644 maschine/__init__.py create mode 100644 maschine/__pycache__/__init__.cpython-310.pyc create mode 100644 maschine/__pycache__/maschine_controller.cpython-310.pyc create mode 100644 maschine/__pycache__/maschine_interface.cpython-310.pyc create mode 100644 maschine/maschine_controller.py create mode 100644 maschine/maschine_interface.py create mode 100644 presets/butt.json create mode 100644 presets/butt2.json create mode 100644 requirements-windows.txt create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 simple_audio_test.py create mode 100644 simulator/__init__.py create mode 100644 simulator/__pycache__/__init__.cpython-310.pyc create mode 100644 simulator/__pycache__/simulator_engine.cpython-310.pyc create mode 100644 simulator/simulator_engine.py create mode 100644 test_app.py create mode 100644 test_hardware_midi.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..342a06f --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(python:*)", + "Bash(copy guiarpeggiator_controls.py guiarpeggiator_controls_backup.py)", + "Bash(copy guiarpeggiator_controls_new.py guiarpeggiator_controls.py)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f506b78 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# Claude Code Requirements for Arpeggiator + +## CRITICAL FUNCTIONALITY - DO NOT FORGET + +### Armed State System (ESSENTIAL) +- **MUST have orange "armed" state** when arpeggiator is playing +- When user clicks button during playback: + 1. Button turns ORANGE (armed) + 2. Change does NOT apply immediately + 3. Change waits until pattern end/loop completion + 4. At pattern end: orange button turns GREEN (active), previous green turns gray +- When arpeggiator is stopped: changes apply immediately (no armed state needed) +- **This is critical for live performance** - prevents jarring mid-pattern changes + +### Button Layout Requirements +- NO horizontal spacing between buttons (buttons touch side-by-side) +- Button height should be minimal but readable (16px for 12px font) +- 12px bold font for readability +- Green for active selection, orange for armed selection, gray for inactive + +### GUI Structure +- Quadrant layout: Basic Settings (top-left), Channel Distribution (top-right), Pattern Settings (bottom-left), Timing Settings (bottom-right) +- All buttons must be visible without scrollbars +- Equal-sized quadrants + +## Implementation Notes +- Check `self.arpeggiator.is_playing` before applying changes +- Use `self.arpeggiator.arm_*()` methods when playing +- Connect to `self.arpeggiator.armed_state_changed` signal +- Implement `update_armed_states()` to handle orange→green transitions \ No newline at end of file diff --git a/INSTALL_WINDOWS.md b/INSTALL_WINDOWS.md new file mode 100644 index 0000000..09d0df8 --- /dev/null +++ b/INSTALL_WINDOWS.md @@ -0,0 +1,103 @@ +# Windows Installation Guide + +## Quick Installation + +If you're getting errors with the regular installation, use this Windows-specific installer: + +```bash +python install_windows.py +``` + +This script will: +- Install core packages (PyQt5, numpy, pygame, mido) +- Try multiple MIDI library options +- Create fallbacks if MIDI libraries fail +- Still work in simulator mode even if MIDI fails + +## Manual Installation (if the script doesn't work) + +### 1. Install Core Packages First +```bash +pip install PyQt5>=5.15.0 +pip install numpy>=1.21.0 +pip install pygame>=2.1.0 +pip install mido>=1.2.10 +``` + +### 2. Try MIDI Library Installation +Try these in order until one works: + +**Option A: Latest python-rtmidi** +```bash +pip install python-rtmidi +``` + +**Option B: Specific working version** +```bash +pip install python-rtmidi==1.5.8 +``` + +**Option C: Alternative MIDI library** +```bash +pip install rtmidi-python +``` + +**Option D: Skip MIDI (simulator only)** +If all MIDI libraries fail, you can still use the application in simulator mode with built-in audio synthesis. + +## Common Issues and Solutions + +### Issue 1: Cython Compilation Error +``` +Error compiling Cython file... Cannot assign type 'void (*)'... +``` +**Solution:** Install Visual Studio Build Tools or use pre-compiled wheel: +```bash +pip install --only-binary=python-rtmidi python-rtmidi +``` + +### Issue 2: No MIDI Devices Found +**Solutions:** +1. Use simulator mode (works without any MIDI hardware) +2. Check Windows MIDI device drivers +3. Try different USB ports for MIDI devices + +### Issue 3: Audio Not Working +**Solutions:** +1. Check Windows audio device settings +2. Try different audio buffer sizes in the app settings +3. Make sure no other applications are using audio exclusively + +## Verify Installation + +Run this to test if everything works: +```bash +python -c " +try: + import PyQt5; print('✓ PyQt5 OK') + import numpy; print('✓ NumPy OK') + import pygame; print('✓ Pygame OK') + import mido; print('✓ Mido OK') + try: + import rtmidi; print('✓ RTMIDI OK') + except: print('⚠ RTMIDI failed - simulator mode only') + print('Ready to run!') +except Exception as e: + print(f'✗ Error: {e}') +" +``` + +## Running the Application + +Once installed, run: +```bash +python run.py +``` + +The application will start in simulator mode by default, which works without any MIDI hardware and provides: +- Internal audio synthesis +- Visual lighting simulation +- All arpeggiator functionality +- Complete GUI interface + +For hardware MIDI, select "Hardware Mode" in the Output Controls tab. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b65273 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# MIDI Arpeggiator - Lighting Controller + +A modular MIDI arpeggiator application with integrated lighting control and Native Instruments Maschine support. + +## Features + +### Core Functionality +- **FL Studio-style Arpeggiator**: Complete arpeggiator with up/down/random patterns, scales, swing, gate control +- **Multi-Synth Support**: Control up to 16 synthesizers simultaneously with individual voice management +- **Volume/Brightness Patterns**: Dynamic volume patterns that control both audio levels and lighting brightness +- **Routing Patterns**: Spatial routing patterns for creating lighting effects (bounce, wave, cascade, etc.) + +### Audio & MIDI +- **Dual Output Modes**: Built-in simulator with audio synthesis or external hardware MIDI output +- **Program Change Support**: Individual instrument selection per channel with General MIDI compatibility +- **Voice Management**: 3-voice polyphony per synthesizer with intelligent voice stealing +- **Real-time MIDI Processing**: High-precision timing for tight synchronization + +### Lighting Control +- **MIDI-Controlled Lighting**: Each synth doubles as a lighting fixture controlled by MIDI notes +- **Visual Patterns**: Specialized patterns designed for lighting effects (ripple, spotlight, cascade) +- **Brightness Control**: Volume and velocity control lighting brightness in real-time +- **Pattern Coordination**: Synchronize musical and visual patterns for cohesive shows + +### Hardware Integration +- **Native Instruments Maschine**: Full integration with Maschine controllers for hands-on control +- **Real-time Control**: Pads, encoders, and buttons mapped to arpeggiator parameters +- **LED Feedback**: Visual feedback on Maschine pads showing current state and activity +- **Multiple Control Modes**: Switch between note input, channel control, and pattern selection modes + +### User Interface +- **Modular GUI**: Clean PyQt5 interface with tabbed control panels +- **Real-time Visualization**: Visual representation of synth array with lighting simulation +- **Preset System**: Save and recall complete system configurations +- **Configuration Management**: Persistent settings with intelligent defaults + +## Installation + +### Requirements +- Python 3.7+ +- PyQt5 +- pygame (for audio synthesis) +- python-rtmidi (for MIDI I/O) +- mido (for MIDI message handling) +- numpy (for audio processing) + +### Setup +1. Clone or download the project +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +3. Run the application: + ```bash + python run.py + ``` + +## Module Structure + +### Core Modules (`core/`) +- **`midi_channel_manager.py`**: MIDI channel management and voice allocation +- **`arpeggiator_engine.py`**: Main arpeggiator logic and pattern generation +- **`volume_pattern_engine.py`**: Volume and brightness pattern generation +- **`synth_router.py`**: Note routing and spatial pattern management +- **`output_manager.py`**: MIDI output handling (simulator vs hardware) + +### GUI Modules (`gui/`) +- **`main_window.py`**: Main application window and layout +- **`arpeggiator_controls.py`**: Arpeggiator parameter controls +- **`channel_controls.py`**: MIDI channel and instrument management +- **`volume_controls.py`**: Volume/brightness pattern controls +- **`simulator_display.py`**: Visual synth array display +- **`output_controls.py`**: Output mode and device selection +- **`preset_controls.py`**: Preset management interface + +### Simulator Module (`simulator/`) +- **`simulator_engine.py`**: Internal audio synthesis and lighting simulation + +### Maschine Integration (`maschine/`) +- **`maschine_interface.py`**: Low-level Maschine MIDI communication +- **`maschine_controller.py`**: High-level Maschine integration controller + +### Configuration (`config/`) +- **`configuration.py`**: Application settings and persistence + +## Usage + +### Basic Operation +1. **Select Output Mode**: Choose between "Simulator" (internal audio) or "Hardware" (external MIDI) +2. **Configure Channels**: Set number of active synths and assign instruments +3. **Set Arpeggiator Parameters**: Choose root note, scale, pattern, tempo, etc. +4. **Configure Patterns**: Select routing patterns for spatial effects and volume patterns for brightness +5. **Play Notes**: Use computer keyboard, MIDI controller, or Maschine to trigger arpeggiator + +### Maschine Integration +1. Connect Native Instruments Maschine hardware +2. Enable Maschine integration in settings +3. Use pads to trigger notes, encoders for real-time parameter control +4. Switch pad modes for different control functions +5. LED feedback shows current state and activity + +### Lighting Control +- Each synthesizer channel corresponds to a lighting fixture +- Note velocity and channel volume control brightness +- Volume patterns create dynamic lighting effects +- Routing patterns determine which lights activate for each note + +### Preset Management +- Save current settings as named presets +- Load presets for quick configuration changes +- Import/export presets to share configurations +- Automatic preset backup and recovery + +## Configuration + +The application creates a `config.json` file with all settings. Key configuration sections: + +- **Audio**: Sample rate, buffer size, master volume +- **MIDI**: Default devices and connection settings +- **Arpeggiator**: Default parameters and ranges +- **Channels**: Default synth count and instruments +- **Volume Patterns**: Default patterns and ranges +- **Maschine**: Hardware integration settings +- **Interface**: Theme and display preferences + +## Troubleshooting + +### Audio Issues +- Check audio device selection in system settings +- Try different buffer sizes if experiencing dropouts +- Ensure sample rate matches system audio settings + +### MIDI Issues +- Refresh MIDI device list if devices not appearing +- Check MIDI device connections and drivers +- Use "Panic" button to stop stuck notes + +### Maschine Issues +- Ensure Maschine is in MIDI mode, not Controller mode +- Check USB connection and drivers +- Try disconnecting/reconnecting if not responding + +## Architecture Notes + +The application uses a modular architecture with clear separation of concerns: + +- **Core modules** handle the core arpeggiator logic and MIDI processing +- **GUI modules** provide the user interface without business logic +- **Simulator module** provides standalone audio synthesis +- **Maschine module** handles hardware integration +- **Configuration module** manages persistence and settings + +This design makes the application easy to understand, maintain, and extend with new features. + +## License + +This project is open source. See LICENSE file for details. \ No newline at end of file diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md new file mode 100644 index 0000000..cdb5336 --- /dev/null +++ b/USAGE_GUIDE.md @@ -0,0 +1,160 @@ +# MIDI Arpeggiator - Usage Guide + +## 🎹 How to Use the Arpeggiator + +### Quick Start +1. **Run the application**: `python run.py` +2. **Play notes**: Use keyboard keys (see below) or click Play for test notes +3. **Start arpeggiator**: Press SPACEBAR or click Play button +4. **Stop arpeggiator**: Press SPACEBAR again or click Stop button + +### 🎵 Playing Notes with Computer Keyboard + +Use these keys to play notes (like a piano): +``` + W E T Y U + A S D F G H J K L ; +``` + +**Key Mapping:** +- **A** = C (Middle C - note 60) +- **W** = C# (sharp) +- **S** = D +- **E** = D# (sharp) +- **D** = E +- **F** = F +- **T** = F# (sharp) +- **G** = G +- **Y** = G# (sharp) +- **H** = A +- **U** = A# (sharp) +- **J** = B +- **K** = C (next octave) + +**Controls:** +- **SPACEBAR** = Start/Stop arpeggiator +- **Hold multiple keys** = Play chords +- **Release keys** = Stop notes + +### 🎛️ Simulator Mode (Default) +- **Built-in audio synthesis** - hear sounds directly from the app +- **Visual lighting display** - see synth array with brightness patterns +- **No external hardware needed** - perfect for testing and development + +**What you should hear/see:** +- ✅ Audio when notes are triggered by the arpeggiator +- ✅ Visual synths lighting up in patterns +- ✅ Different instruments per channel +- ✅ Volume patterns creating lighting effects + +### 🔌 Hardware MIDI Mode +Switch to hardware mode in the "Output" tab: +1. Click **"Hardware Mode"** radio button +2. Select your MIDI device from dropdown +3. Connect to external synthesizer/software + +**Available MIDI Devices** (from your system): +- Microsoft GS Wavetable Synth (built-in Windows synth) +- LoopBe Internal MIDI (virtual MIDI cable) +- Other virtual MIDI devices + +### 🎚️ Controls + +#### Arpeggiator Tab +- **Root Note**: Starting note (default: C4) +- **Scale**: Musical scale (Major, Minor, Dorian, etc.) +- **Pattern**: Up, Down, Up-Down, Random, etc. +- **Octave Range**: 1-4 octaves +- **Tempo**: 40-200 BPM +- **Note Speed**: 1/32 to whole notes +- **Gate**: Note length (10%-200%) +- **Swing**: Timing swing (-100% to +100%) +- **Velocity**: Note velocity (1-127) + +#### Channels Tab +- **Active Synths**: 1-16 synths +- **Instruments**: Individual GM instruments per channel +- **Global Instrument**: Apply same instrument to all channels +- **Voice Monitoring**: See active voices per channel + +#### Volume/Lighting Tab +- **Pattern**: Volume patterns (Static, Swell, Breathing, Wave, etc.) +- **Speed**: Pattern speed multiplier +- **Intensity**: Pattern intensity +- **Global Ranges**: Min/max volume and velocity for all channels +- **Individual Ranges**: Per-channel volume and velocity ranges + +#### Output Tab +- **Mode Selection**: Simulator vs Hardware +- **MIDI Device**: Select hardware MIDI output +- **Test Output**: Send test note +- **Panic**: All notes off + +#### Presets Tab +- **Save/Load**: Store and recall complete configurations +- **Import/Export**: Share presets with others +- **Rename/Delete**: Manage preset library + +### 🎪 Lighting Effects + +The arpeggiator creates synchronized lighting effects: + +**Routing Patterns** (which synths play): +- **Bounce**: Notes bounce between first and last synths +- **Cycle**: Notes rotate through all synths +- **Wave**: Smooth wave motion across synths +- **Random**: Random synth selection for sparkle effects + +**Volume Patterns** (brightness control): +- **Swell**: Gradual volume increase/decrease +- **Breathing**: Rhythmic in/out like breathing +- **Wave**: Sine wave across channels +- **Cascade**: Volume cascade across synths +- **Random Sparkle**: Random volume variations + +### 🔧 Troubleshooting + +#### No Audio in Simulator Mode +- Check Windows audio settings +- Ensure no other app is using audio exclusively +- Try adjusting master volume in simulator controls +- Check audio device in Windows sound settings + +#### No Sound in Hardware Mode +- **Microsoft GS Wavetable Synth**: Check Windows volume mixer - might be muted +- **Virtual MIDI**: Make sure receiving software synth is running +- **External Hardware**: Check MIDI cables and device settings +- Use "Test Output" button to verify MIDI connection + +#### Keyboard Input Not Working +- Make sure main window has focus (click on it) +- Keys only work when window is active +- Try clicking in the window then pressing keys + +#### Performance Issues +- Reduce active synth count if sluggish +- Lower volume pattern update rate +- Close other resource-intensive applications + +### 💡 Tips for Best Results + +1. **Start Simple**: Use default settings, add a few notes, press Play +2. **Experiment with Patterns**: Try different arp patterns and routing patterns +3. **Layer Effects**: Combine musical patterns with volume patterns for cool visuals +4. **Use Presets**: Save configurations you like for quick recall +5. **Hardware Setup**: Use virtual MIDI cables to connect to software synths like VCV Rack, Ableton Live, etc. + +### 🎨 Art Installation Usage + +For your art installation: +1. Set **Active Synths** to match your physical synth count (1-16) +2. Configure **Routing Patterns** for spatial lighting effects +3. Set **Volume Patterns** for dynamic brightness +4. Use **Hardware Mode** to send MIDI to your synth array +5. **Maschine Integration** for live control (when connected) + +Each synth channel will control both the audio synthesis and lighting brightness simultaneously! + +--- + +**Have fun creating amazing arpeggiated lighting shows!** 🌟 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..43637a6 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# Empty file to make this directory a Python package \ No newline at end of file diff --git a/__pycache__/main.cpython-310.pyc b/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5651f504420ac0f2fabf86b51c930c5a88708a1 GIT binary patch literal 3011 zcmai0&2QVt73T~oilV3w%Z|VDQ69Q21ayV%p_d{^6F5PUL)@h9c7Z{536`8lborso zjO?``qeYcVFGc$w$jAH@?Xl>wN8XB_eC&0*DEi(|bY!F5l`u1J-q(D*- z{l)$B+bW1gm+w3BsNSJSPum-Sg+({8$v4cI`_?Q}ESVq2Q_(mUBU+tzd^ z-N|;@E-`*;#ND{}&WL*p%J%qv-2XKd5#L)F>@FwEd;IS5{-tqAz=|H}HrzP>gZtw7 z)93yZG3DcN5~@P@ANw!DB=?_8r)d&}Dk*aRS+2yH|FTGO;QW_g;Z ziJvCpiAwUZ9~EF#q<)yk{vr4|;r{bnDlyA=t|TlXqe+r;ADr^B&b*V$3KdfWra-tqqOE{yjOjW}Q4 zkBLU60q3TNSA96o@PWWPD@}POODh!PlhO>QQ_%({vjJTY0)aPB_M$4ZK~}*)w2<35 z(eVtpMuBk9%##@MK-dTW&buH3L|yx`k=b5@x$w}_X0{4>u);aST;MxV zwle@|aHDg`=n3KcWy#+RIhLYqj-1NG)CdM z!dM$ia%t$O)1|qhjAKju9%PNBUEx4AV;Y<9P~SOjE!|7wsj(mnazW-qyqS}y#^<{i zR9m#?RC#ldUD69vb>@&sTcCRZxIOMJdviLsE-5kIegyOWoa`DGjd=s?_q4s%h#RP( z5W|Dx&DdU0@kK=+Z!Pc4Y24Hvp3TW}`x5i2A3I05xBj>K`Q88Ulc=W71nmVq4O{0w z_*7%S57X3N#o1SrQ29CMu`l>ozqo?G5nN8jU%&s?-(URdk3YF_*@z_s;1M*r3*8vD z___0%UJy7c6?9JiMBl>GukoV0p;^+=CyYiDW}g&E#N`K>_zt)z-G^x+Ra9itM=#MY zX5_AM{=-|*zlqS%!?cLPR6YWtw!ZoeSYE~GbK^SWf;f@WG(5Z70{-D}M{J|quDl6k zvYnOdx~$YD%uQr~woq!sCTiAcGOZqPfma})nj5UMO1tWaWh22fVuLYPbtVSYTTz-R z&r1_3lDLD01IhDvCDW=Uf>-Z)Z#+vzYsEm92eui3!su3!!OFvSv;?qJ ze~Q(m3!hN7r==PXx62(CLAIggH|m=%dHb5bs)eiEs2yXSWc%0YIJ%~41+0*4tE#o5 zYFK^sRAu;HT?OHs)#qHD2UrfVQ0f~MBcu=krNzHY)c=i2={&3=`UpWLzXzQ~oCek? T_*30iwC=mqfv@ZVvHtU4fRrc_ literal 0 HcmV?d00001 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..04a8470 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1 @@ +# Configuration module - Application settings and configuration management \ No newline at end of file diff --git a/config/__pycache__/__init__.cpython-310.pyc b/config/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa90d94f42c1951e364a5a86ab2075ca78c1ce16 GIT binary patch literal 130 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 nvtGk~>=9ouM9`fU+=~)!;)2J1TuFl* bool: + """Load configuration from file""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r') as f: + loaded_config = json.load(f) + + # Merge with defaults (preserves new default settings) + self._merge_config(self.config, loaded_config) + return True + except Exception as e: + print(f"Error loading configuration: {e}") + print("Using default configuration") + + return False + + def save_config(self) -> bool: + """Save current configuration to file""" + try: + with open(self.config_file, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except Exception as e: + print(f"Error saving configuration: {e}") + return False + + def _merge_config(self, base: dict, update: dict): + """Recursively merge configuration dictionaries""" + for key, value in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._merge_config(base[key], value) + else: + base[key] = value + + def get(self, path: str, default: Any = None) -> Any: + """ + Get configuration value using dot notation. + Example: get("window.width") returns config["window"]["width"] + """ + keys = path.split('.') + value = self.config + + try: + for key in keys: + value = value[key] + return value + except (KeyError, TypeError): + return default + + def set(self, path: str, value: Any) -> bool: + """ + Set configuration value using dot notation. + Example: set("window.width", 1024) + """ + keys = path.split('.') + config_ref = self.config + + try: + # Navigate to parent dictionary + for key in keys[:-1]: + if key not in config_ref: + config_ref[key] = {} + config_ref = config_ref[key] + + # Set the value + config_ref[keys[-1]] = value + return True + except Exception as e: + print(f"Error setting config value {path}: {e}") + return False + + def get_section(self, section: str) -> Dict[str, Any]: + """Get entire configuration section""" + return self.config.get(section, {}) + + def set_section(self, section: str, values: Dict[str, Any]): + """Set entire configuration section""" + if section not in self.config: + self.config[section] = {} + self.config[section].update(values) + + def reset_to_defaults(self, section: Optional[str] = None): + """Reset configuration to defaults""" + if section: + if section in self.DEFAULT_CONFIG: + self.config[section] = self.DEFAULT_CONFIG[section].copy() + else: + self.config = self.DEFAULT_CONFIG.copy() + + def get_window_settings(self) -> Dict[str, Any]: + """Get window-related settings""" + return self.get_section("window") + + def save_window_settings(self, width: int, height: int, x: int, y: int, maximized: bool = False): + """Save window position and size""" + window_settings = { + "width": width, + "height": height, + "x": x, + "y": y, + "maximize": maximized + } + self.set_section("window", window_settings) + self.save_config() + + def get_audio_settings(self) -> Dict[str, Any]: + """Get audio-related settings""" + return self.get_section("audio") + + def get_midi_settings(self) -> Dict[str, Any]: + """Get MIDI-related settings""" + return self.get_section("midi") + + def get_arpeggiator_defaults(self) -> Dict[str, Any]: + """Get arpeggiator default settings""" + return self.get_section("arpeggiator") + + def get_channel_defaults(self) -> Dict[str, Any]: + """Get channel default settings""" + return self.get_section("channels") + + def get_volume_pattern_defaults(self) -> Dict[str, Any]: + """Get volume pattern default settings""" + return self.get_section("volume_patterns") + + def get_simulator_settings(self) -> Dict[str, Any]: + """Get simulator settings""" + return self.get_section("simulator") + + def get_maschine_settings(self) -> Dict[str, Any]: + """Get Maschine integration settings""" + return self.get_section("maschine") + + def get_preset_settings(self) -> Dict[str, Any]: + """Get preset management settings""" + return self.get_section("presets") + + def get_interface_settings(self) -> Dict[str, Any]: + """Get interface settings""" + return self.get_section("interface") + + def should_auto_save_preset(self) -> bool: + """Check if presets should be auto-saved""" + return self.get("presets.auto_save_current", True) + + def should_remember_last_preset(self) -> bool: + """Check if last preset should be remembered""" + return self.get("presets.remember_last_preset", True) + + def get_last_preset(self) -> str: + """Get name of last used preset""" + return self.get("presets.last_preset", "") + + def set_last_preset(self, preset_name: str): + """Set name of last used preset""" + self.set("presets.last_preset", preset_name) + self.save_config() + + def should_confirm_destructive_actions(self) -> bool: + """Check if destructive actions should be confirmed""" + return self.get("interface.confirm_destructive_actions", True) + + def get_theme(self) -> str: + """Get current theme""" + return self.get("interface.theme", "dark") + + def export_config(self, file_path: str) -> bool: + """Export configuration to a different file""" + try: + with open(file_path, 'w') as f: + json.dump(self.config, f, indent=2) + return True + except Exception as e: + print(f"Error exporting configuration: {e}") + return False + + def import_config(self, file_path: str) -> bool: + """Import configuration from a file""" + try: + if os.path.exists(file_path): + with open(file_path, 'r') as f: + imported_config = json.load(f) + + # Validate and merge + self._merge_config(self.config, imported_config) + self.save_config() + return True + except Exception as e: + print(f"Error importing configuration: {e}") + + return False + + def validate_config(self) -> bool: + """Validate configuration values""" + valid = True + + # Validate window settings + window = self.get_section("window") + if window.get("width", 0) < 800: + self.set("window.width", 1200) + valid = False + + if window.get("height", 0) < 600: + self.set("window.height", 800) + valid = False + + # Validate audio settings + audio = self.get_section("audio") + if audio.get("sample_rate", 0) not in [22050, 44100, 48000]: + self.set("audio.sample_rate", 22050) + valid = False + + if not (0.0 <= audio.get("master_volume", 1.0) <= 1.0): + self.set("audio.master_volume", 0.8) + valid = False + + # Validate arpeggiator settings + arp = self.get_section("arpeggiator") + if not (0 <= arp.get("default_root_note", 60) <= 127): + self.set("arpeggiator.default_root_note", 60) + valid = False + + if not (40 <= arp.get("default_tempo", 120) <= 200): + self.set("arpeggiator.default_tempo", 120.0) + valid = False + + # Validate channel settings + channels = self.get_section("channels") + if not (1 <= channels.get("default_synth_count", 8) <= 16): + self.set("channels.default_synth_count", 8) + valid = False + + return valid + + def get_config_info(self) -> Dict[str, Any]: + """Get configuration metadata""" + return { + "version": self.config.get("version", "Unknown"), + "file_path": self.config_file, + "file_exists": os.path.exists(self.config_file), + "file_size": os.path.getsize(self.config_file) if os.path.exists(self.config_file) else 0, + "sections": list(self.config.keys()) + } \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..1ba3081 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# Core module - Contains the main arpeggiator logic and MIDI handling \ No newline at end of file diff --git a/core/__pycache__/__init__.cpython-310.pyc b/core/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ed7c03e518c41c5d4283abd8406613635dce516 GIT binary patch literal 128 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 literal 0 HcmV?d00001 diff --git a/core/__pycache__/midi_channel_manager.cpython-310.pyc b/core/__pycache__/midi_channel_manager.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12d70b07b884aa2a3787eef6bf7a3033d05f3e51 GIT binary patch literal 7573 zcma)BNpBp-74B^oHfJc3qNvT5+e>V9EK#PCIGf^_i%2;(MVcgJqa$O`oGQ+=*wa0# z?jg+vjRcT@9FkiOIXD5x067TADabj$AitomIq95R5+nIub)oELCXwAQCMTbG$wDN%?>%c?H3z)a>v5xCoZ4owv+mZ?mm_ehqL;tYd z32w6(FrR8dDve8@%vDb&B+hn&y;bt&G}OLt)wFgQlC3YFoi}{$DMFzXVu0nb# zmy*>lv6msaoJ$IjTw||5a)nv!)u)Pc74vxsSiH2W$vM5oUdPjyf2A0Ta}6weBb_Gx zmi)%F@aZyQkYJ!spH?s6lC>KOtJU#i4@;WSD4LTa-gWEEF0Vo%c+?I977^dv z(=@(5e{=p_JL_csGMQPZhn*+}tyTr`Y{PSdFuAZOyhby&dJi-4%v~S+5oeSq?}fqM z;msIj-hHyCC;M6R$yv#5XZ)Tk+L2dphJrceCEwlm0vauVTcJN?bG~6JW+~A_#f%oq7Tw-qGjd{JY)a(dAn(3OGoU^R%j4jmbuo=$fjA0Hf z@<}&2x9tXv5P;>+l8Zg;Ry$sdw_XomVqlMW5XGGPb~4*Tt=r>~n$X#T;Dx>yI0gE3odVe4 zhmN@v28ee%WYoEB!CRppT3aAu(1?<$4KGQs1fY6XbZ#9>2NnqgQ=*wu+V)y)H}I0F zOvSnzM7*^FADSN)-A>MB7Usi2=ZxTXVlFj9Pgo0XGfK<{GUBg?sE=nor6j5L%&_ix zp!PPlj5au4-WFkNhxluNt8P+Vqh+)5$H_Q=3_!hmp$L-dq9E+I;CEQMq?%(Vh4ZJI zxAFF{4KD@|?&nE)!6!&i8{6gq;s#YKi*OitDyNoXtnY+8soV`3z87ULbV{@?A;?WW z353ZfH$2c9>rhznVX0HzUbTme3v?5qCj$5=l!ygS-htcESVlze#!uD_T4z8ja43qpPGO-DHbU@`BAc}g-bc>ZCt=8TD5xjq zw(ISMQl5;jaX(~;?b~51F*ABohh$=tBZ@`bS~|p={bVW+R}^Nf`ys3Y=LC0=JINM| z=5B=TgeT^rC*V&&H%=zDv7yMF!P-Q;aLjugD-yP2E}Zg0yY0K32-IBd1n3*rk@fUG zRHqD7$2a;2MXWqgj@1)|^2{TprZEkr&UBOpGg#rN5+mnfr6=05@`N;stUN+mVhZwu zvHQ=<81wlg74(Wz@75rocW>bvO{4IXBjvNg7wQo*vfpZtDUVeJS@=wOrls24JE`i4 z3fi*x*R7==J#2XKLqxweZ+M7~;vt-tKWur-tL3R@Ez4=eT>CIJ?_d$)de~~ew}?^x zNd`qqa+_4__2lb%nZ_BKLVsdT0$H{`$#+1kJJ+tQgDQx z*hcc^7JMddax3Vxc5u)Mcdcx0c@Al1S)n+Omu#=thPZ@^xQHUXG7PJvPlL16;4Lq< zHF1r!U!|f)KtY(Kvk{ML!vn11nS|+*W*EhDguMT9HhD^Z$3C01gb z4+#mTUR0iFPgG!_B{x=q0rt6=W?%0hM-F2Du!}>!{y9LAM8eingF1*S7*(PnUZ&ca zNuF=?Cz%futwS%O^_$U>H%H8CM3=ObcTP+B0kBSs-~CXX0)m|815`$k!IoqwVA-`J zEhow)rh_KECr1cbk3ge42qzg;j=7D7|JO~oK=5;2M08Q9KC$MO9E z+@qhPcQk=wRx=8f|Cs7zhS(^6j_7_0E~0jijdN&>VB>{eXaYpX0EU5Sy^ti`F?Yj3 zOuCmJ8QX?Pb{~w9!Aa6fhaWx5bG^&h)Zw7&m&zY#7uS$zYSz9m1B@noRTwmQ>Slj9bNmHM3Hd{+Gv%SE}wscCbqu6$ms=uCQGeF9AO>PsL>vY0*V87qIk_%t&gv5jYo~im9oA z^!6~-+hO~eF%VwAD~HIEnX_SbqC251Lux}cqFv7{8mg(4bwi!dO?4Up!_0q^S4V7X zr$>1)FP1U%H}Jy3DkHGwERabJBu1YF#3ne;Tbs!5U97-2tm?|PU66RDNVQrajuw`p z^20b<>CUX@31ZaDroAXzr&@26FUcqhYqVlBYVxfS(;MzJG{0Uvqs7X?I_+n)Tr#;- zlX+UK?rt`eWM@7%v|4gH{UsYUr*i~;=I!#FZ~_CwPf;YVjhMa{-4-L|}@};^l7bSf}GIS~3pPjFH@w<-bwjdrNRVB!&GHCZ8#VQwVcn zX4&+8yO<@?a{H!fTorSgW0h&0I*+TR^ z0h>wx6eE+#?_kPVz>wju=UaJ7o)$BU#HhS#nwXZaZY|UMGlr8F6^1sWmbD4Bg1>3C z@^7Ow((Q&vuwS9mJWZ%pOU+uXCI1_TdZkvwJ)xgJDb;E$tmEii!W&cF!_DyPG!>Z~ zzezP2*eM$mv!r6|grP6KMQvJhA@k`QRJ%z9MPl&|id4rDMMzkFOtPO)@g5cLQ?Wn= zkwuUzi4_!SHNOa`z=@m1Hk_T>K1uQUg~uVOoq2+ejI*m&|a#cNr%yxLflZE9_JB6&QT>KTqS zFIG1tPlxmxO9Oif5Fp4Y2|(Qv16je)zuj~4)&Q`k?^rYMA3D2!{g}YIc;$cmCoU@Ys3G&*W@AhS{DQg|K zzESHoJ^ux_+Y3Cm*L|tiXt*8E_c1ndsfP!>_EyJp>%DFud+n9%KDJ$3D_fEEPOBb7 z*843#h@6}Kpw;Wv+EMD(R=@3)jVS%$&AazJ)X4S+o56akiPBr)$|p71#hh2&PD}J? zShx0|)@s-8w!IhJy7X$iqDHOVzFVu`zZRYvHIM(prYXCgt*AK~$rK(_U5t*UKn_w+qu+jvCK(AKrb z`dZnHtYFahBBxpv*ota3vbXx87I;+Rd+i1g=Qpcd$G#6PH2ue#oJXJEmanefZnlEk zHQDcb%@*VU4LL6iTFcv%Ln*UquB%cLrKg2;NNEbC&kK80YBd2^d_ho9vMQWWNeU^O zdTnB#%9*t&P2j5by8C~IiA0Y2YBcu0qR&DM#j1kh{;xPQM%ys+?~qweOHvF`nksg^ zc4SdUk@LW7_v)=+5T)auP(xugNyjVeidox39L-l zI)Kp8j0n{vRWgGFk6YHkUvxb&2_8Dv>wDdJY*+_1s%3KqY8&k+lFN}kiO#SyWH+{IxoS{@Zk$T`Y6 z$56)-%i=h4mN@4G=bRKzA?KL5M0pthlsNr$sM|5M}WkzE0!o4ElXuynruPyeL-C&(q>1@iN|Lf2mnoyq&&=RQQ zd$kVCCK3-@L49M==8~oLV^gbaD@>OXsCuu{+3L3Hz*?`XdR$A`8j=`-zDa=j93;?o zwLSe2+z>%l$6(uI3h3FY)g+qYv@Hx_eys&*0aLbXyrb<>ZAaKMax>f7UY2_#ZJb_< zG9xb`A&ygR8KeH$k9&y7uTPxO6(0v40UMr5qK9OwMO{(9B`tg|!KsnaTDc76U2FSqu8f=QlUmp(HG1d-ym1!36O|RO+{Wp z-Y1m*3P?E@rTneFCs{#9`TCZWUN;~u?nTAXo>aYVv(@#Y9Qhito{1JlE=ru^NAs}n zaBe&?tdo-DNk#Lqb*d`2$&ZS|jMzH)(W3Mk((^Z}JVXq`vZ0LBtC;FzJ<3$8^>)qo zt5vlqRzq!#J6;U2oZ z#@NeCc(C#6!!z$n2|{m}|>(?>yDD&?yYOssCucD%vZWVn$!=nI z36BJL-d);ea*#-%NqEAQ_q^bV_QPePimCHDt$MFNkS=OM1)$7e`CKQmO?&tOlU$8;iRm?Ozg z=e%3j$|?B=RJc&}2iGRJRPz@>90TI07mX~SjB!`WnoI*BxlLvNwB%gnaZpj4P z(;nzDE#R;rt;^$Kn%-(paUQkQNt9#}Qmlt3gLs$ei%kf->$5wu=HERyIAgxFLAPBxkrlSJ{_JRUJkc?hNxd# zV~*VlPbi5eZ*5B4-OyNd%X18V`5Lw$O4Zvv-;-C6R*u>#t?JPU_24HzoK@h(TRl z{+#Gu4l^+^LhhtSvq4^dFQaBB@{fRV))sT&;<|^Z1c@@@6~kDCq_e<2TS?Nv`#%R+ zW3`|Z1UWR1&8h7Vk0}iL=#VfN_qpmeQCrh~RekFgU`Q%Ol3A`~`t90)l?cO9;gJs% zIPG`E1lUNAFyf+l6%nYmTg?rMY(yCvrHcAN*;a}siHXQW%wQ%s^E3z#kF)e&U2<>3d5r;W6+OjS|8G25cEww6lcD?`>;QUoyfI)7Tt$S0`;h{7WG*LgIhp;{Xzm z&p_fb+F}1$cV|4Ksl@fpSxdX|&Lq(swf{fP?~8H zPc#pCD4vcmo`#Lp;QxRY{&|o|RKYERV?>&1aY8ed%<$T*2Ux(tZ(d^h3ez9V^o+8sS-AB#7=~|#iiUwhUdh0N17FF+@$c7#{CVY612oq6 z;qnZ9!krur4ctkOp~3fNJ`Q;6T3Eq$8++y>4FI`t8-cYQgjPm}K-|sYsQ05ToafCV zAIzYMgh>Z807N-9`p{7hh}CIwaW{+TH52!Gk$Yv>#mB_!+-r?{ePeXoL$9!C*2tpG z>i3ONxG+2T#RSN5d(bbz+yRgo3^Elc1l$N@&_O?3N36z;qp_oV>4}SH*cduE`2?|z z_%@s2wKiet$Z?W)K$^dK{6GJCwqLJgeABkyv6YS+F5Ulu;vPl2Hcc z3H3luZB4Oq~e{|5PBSWKMIX5hMityzU5a(&GCaI%_p(V8d<1< zEX9+jLO7Gv4=j*;{KNtg?!{FEEd~!l#GFpWZ=2pi|0|n8cAmM0OFZd88vWJ-&!s!_ zEgyHK09%KzG@lJo_J#vO@nuCKuuk`@*c|`> z4}|dORfYTp~#*bHO{b7K&Gr>Tu$?GFbd=cqA!u9Qr=6 zh7ox4=mK-s*hN!wTe{lzYTd0qg-3BW9muUZT|lqlzVxsuMQQwm$!k}uYcwtDJdZUd zClc4-pJm+$jyhc*-lH#mU{)54X!H-rx46fqmUy`t?w(ts4l>%-- zZ&t5-eC7K4SKfaA-6(gx)9=ZEZC;e>OYCdKR=5*Wcs!ea+tdysh9@J@pSepS-0gzoyFc&p_q(aYgr0Fr}d|m1!Ndp^CfK&~Vo~ zde>+eU9(|!t%lVtGzu#9Gdjg?sZr|M4I6cnS)I9VxlvY?j}%s5#U~0Y`lSQCG0(Iu zrCu8ToxQae#yig4-XQk5bEn4!9pAP^HFECUzIEFPd$AumanJGkectQyz>9q+5~G?s zUKskF$l3NHpE+W^ zLG@cLzZpjk207h96f`k{1yRg{Z4B#$>-G`2qHNZU#JCkSye7Fbv0R}FWZo-zvG2o+viQ1cKRna354jk800Hp zze22U=)U2b^anOtcrF~MjbdA2+Fz@U64QOVrTt84VJn#Vq}-TemS126zuYodk(Hhp zV7={|Esf2wGHMkz&ni!p#sYhbReh74z_sY(X%!phqqeHFitHp?dSW&fMUPW6trn}X zWz2MvO&wW2ZqqHA6ka&VpK-p`K&sL0z7qI_v_dD>KxK>=LM}Gt|rM z3aD!{)T`_zP*2ZLudzC)XJ)7`v+safkwavlfJ#OEbbD9COvX!?%V>GYaVBhoa?nChR?6(ZdA!+2e+uD+0z zBj{Micg4bzIVfGoT;AIb!lc~X>2c;peeQcKsc_8H>$>Sm6Kfk?n|@+8_nI9)F&=xn zeq!;U53NcHO)qMC%pZ?RzbPq2{a!4TIGOW0r1@B7*iP&w7UgpnI-gikKj6?%YZ$bL zUOQPxm+#8u^QJbXe)LS2P8#?LidZ>No~nn6s*Kc;;zAQq>P$mvFdd~So~^cm=YpUV z1qC_@iX9Mrjw#qtW0TKeMqZ>sScGz7MuWc3lcMVeVGz46Uqd67IN%&#;HG<)EWrMC zJ-+9rYfq|ai`(x-0c}ijBBQW?!~76wzT-u4HY7?GB#S3QswrVIq9V9I>5=WyW1kFV z1oLJ@ey1f?`AkhpuG_>rBG-MU{Pv}d@853+@qO507-N|09>3r0asR%M1qcC|`t|-E zKZ8%aj4P_4uvJU7^z=%fY<;*iCHw0cbCPoQjiJ{i>G0zL51-pV+Pe*!|2hiVwFkF6^B2NMak>K1&S08^m>uU&Rpq9V+VM;$<-n zO+ws)AZG&bduWKtC~VDAi)vXl@Q1~cI}|p7uOBlCdLwN_M2y9 z{1*B}7K$~^R*i3;|Js@UO;V}sM>0TwM#ur_vCv!WGPDtZ2BjvRb@6OKM$D&HDi?*P z#Y1HuOu41}q_D4%9zQ6Jw42JK)64)H%;D9$w9^@sxDoaJX3z?nj@WW%*8^DD2KEq2 zE*SA|a$W!a-e%qA=TYGf6&I+3-b?1BF&B=gNsP{Z zv|%PL!T1p#Nd3rw?i(W`Dv_#fusl^mh%kfcv}YNBQw zYWwEMIpDdH=p8>y4U06M5P>NNT65(~-Pq$KCYfibq|VxRh3`)0XpiIsjK4|~ zpK=F%2HTVw7ip>LmNZGk8wD~Do1+>2atujSh0PU|pTS#dsFY49d zn_EIG9l3jB*&IhrnP}54Ooc`grI0n{DCwc}@U_rpPhtd(xdkkQs5&ogm4KR>4?8l#{wdmTA2%Z8H~& zMCvo6F8n>v`nV$MVe2cZ4F~uCy^8wq;1 z@wG&u(QprM{7{4)F!#-eBDMjTK{T}O$B%tKbXozAVnHV;J^uCygg^QgKkT^R!X>Oy z`1B0w9Jl))qtoBwib#dZS`l7&5gu9lhM=9T4cBtJnp1`xf2MbnZ(%O%+xZO&o}Sw( zCkWw)fXT2f0W@B?X9N@e9vA-t#c(yZlxaQ8t%UYBldI@;RAM-M!F6>>ImG@f!E7%> zx?+8##c=TC_*axArId5WO^^*zCRfN-Ph#XE@( z;3?!V(StA++fX;8BO~TF=7qV)f~3ZL>Ln3vG5_mXi6xx)D_jC~u4qd@+QXF`52lPy zjs+Cbjafjtor&TaDHVJaQ`e?wOj}lJ%D&zPDgqJ$LjW~T-UrUvx46GMO zK}sw^(WK-9t@xA+NOW&IN-UrvKVirRldU3*07-|t(H|A5*%RLqhB1GJH(J0a>yNU7+hAXU~ok;_;gOHv&p4z%yF3DJYDo1(JxW_ zxd35I`!k3ggtkgP12h$2Y?6(VOrFMWF z&8L8z5>{e6*d8(l2+-0z#?gPnYHxA^tGTF=^n>K-d$=NsS$_)n_!E0yrLTrfLoW7E z`x0+`ggg-ZhsNJPr%l|i;GXEf;Yg8a>#rtSbLW{V_fxn<5!sh+w*Aomy3gPJHNAig zhW5=F1vqUgj(-7SS-(jsxK8x$`gjrDS%9u5)HZ&(c*2{ewEsVQzHFr zl;d6u7$cd|C=x<)cfC%-ZVtMG4l-#FVzK%MG>$%j-$C&&xCD0=bz5B|fkGT<6V8W# zE#fMw+PAB}RHpd8{~UEXuOWuLdn zYcPXUI0RQo9kW@`8*#ZDiA2Xs@25#se+-HIUEm@}iqm7`a5v(Agq2Uy9h=XziGRuQ z+1T+qt;SsBH+v8^s?7R^PchSf;)*EPAbeEO%GyOp*NR%3NqHRu*mLqksyucNEmU&6 zNclFQWnNkG0^BCwd!Dip053*}*m0z<2#?M5SWm~nQ;&=T?Lb3*1Gx%eh75o>8F?M! zge9wl6jF%w%$RU0(YxN)D1xvKZ-hpvt1g}zh?B_Aa-YaENc%I;EKC+rq*m6})Cv@{ zsI7o`!*e-Nd@h*D@hhi)lr_qD1Gz#{JH+{)L^C68DQzPZgYT9Q&OnY%L`Gu}Hybn@ z(JQ7n5TcrskGU+gihZ0DQ0JL%{T#F<`c_i6B|v9=%8d5#9+oyO0Syah z1VN7_#j*Q8jx`h{ozBFdikPyDh+;*GL7qjqcr};4BSb~ADa_Fh9-s_q`fdm?ilhp> z^}hL)63!X$*?Y?>ydh=K_su_qF?p;2-@|~QG!9gF>6gh{%bJKVfwcfykD=AI;q@(X zGz6QCU?n~OO``x7;Cz-&L^6XebE{y|BwuCLUe_dXZd~ixh(7QZW3V z@Gycqpgh-GIEQkH{i792qlRK>F?a$G+Uqb<}6Vmv)jL;>ha5)P32 zIx~^{E`+D31r|=_H$_-n2+Lv?%7EP#vlP=cAjbuux0HH`5`dl?+pK`zCDMCVTviT9 zb5H}|`w=Sv7}>-67b3?vmU)>P_gXju8fQXfE>;92N5li^Kt!gA0F6vH5x!~v;^*}H z71HN(5=LL3c7dNMmQD0N{zKp_(PmQWSBUlqMP_9l6JZR2W;gtYXuU}rei;vNJYvIb z1Se!?=%@-|I%q}M*EKvFzZ+i2+1KL|t2u;;xhMKXCLW9vn~Xa#k+xWzM&NEeEgV7+ zepw)-IlS@_P8_m~U7Cze4;+FJSqp^204?=GQb-$lgy}`*ZH6h01so94>0&C65#l?+ zJBkL68z2uRjAjpqcLECLZE{kj{Bw5BMT_~8SV;cc&?yTn|B9;hU&Axg?3x``S2ycd z=q!|u_gpuzT^C15_|FhLmtFVK!0V(f1=nT0CXP$VUhxJM@1sCEAm;oPs@|ZYLLM_;jVBU%kvl0WhFZ3Oq4h<3ak)|| znwKl*R%@@;%-RAjt5(5Z4fh4y3m4~WOSLslGt?{m8{GIGQ1Ky(#ESR&NbAbQOum&g zEh&Gt_j&xr`seWmPG~v#Qo+h(sT;5$6O8PHT=J1#GPQp3CA9vecvJ4uyCmEKgBVJ& OP*hhCahA0u?SB9h^6?b_ literal 0 HcmV?d00001 diff --git a/core/__pycache__/volume_pattern_engine.cpython-310.pyc b/core/__pycache__/volume_pattern_engine.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a3ccb08a7689e5f59a7a8ad8356349eba5219d89 GIT binary patch literal 8699 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 end_time + + # Threading for timing precision + self.timing_thread = None + self.stop_timing = False + + # Setup timing calculation + self.calculate_step_duration() + + # Setup update timer + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update) + self.update_timer.start(1) # 1ms updates for precise timing + + def set_root_note(self, note: int): + """Set root note (0-127)""" + if 0 <= note <= 127: + self.root_note = note + self.regenerate_pattern() + + def arm_root_note(self, note: int): + """Arm a root note to change at pattern end""" + if 0 <= note <= 127: + self.armed_root_note = note + self.armed_state_changed.emit() + + def clear_armed_root_note(self): + """Clear armed root note""" + self.armed_root_note = None + self.armed_state_changed.emit() + + def set_scale(self, scale_name: str): + """Set musical scale""" + if scale_name in self.SCALES: + self.scale = scale_name + self.regenerate_pattern() + + def arm_scale(self, scale_name: str): + """Arm a scale to change at pattern end""" + if scale_name in self.SCALES: + self.armed_scale = scale_name + self.armed_state_changed.emit() + + def clear_armed_scale(self): + """Clear armed scale""" + self.armed_scale = None + self.armed_state_changed.emit() + + def set_pattern_type(self, pattern_type: str): + """Set arpeggio pattern type""" + if pattern_type in self.PATTERN_TYPES: + self.pattern_type = pattern_type + self.regenerate_pattern() + + def arm_pattern_type(self, pattern_type: str): + """Arm a pattern type to change at pattern end""" + if pattern_type in self.PATTERN_TYPES: + self.armed_pattern_type = pattern_type + self.armed_state_changed.emit() + + def clear_armed_pattern_type(self): + """Clear armed pattern type""" + self.armed_pattern_type = None + self.armed_state_changed.emit() + + def set_octave_range(self, octaves: int): + """Set octave range (1-4)""" + if 1 <= octaves <= 4: + self.octave_range = octaves + self.regenerate_pattern() + + def set_note_speed(self, speed: str): + """Set note speed""" + if speed in self.NOTE_SPEEDS: + self.note_speed = speed + self.calculate_step_duration() + + def set_gate(self, gate: float): + """Set gate (note length) 0.1-2.0""" + self.gate = max(0.1, min(2.0, gate)) + + def set_swing(self, swing: float): + """Set swing amount -1.0 to 1.0""" + self.swing = max(-1.0, min(1.0, swing)) + + def set_velocity(self, velocity: int): + """Set base velocity 0-127""" + self.velocity = max(0, min(127, velocity)) + + def set_channel_distribution(self, distribution: str): + """Set channel distribution pattern""" + if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS: + self.channel_distribution = distribution + self.channel_position = 0 # Reset position + + def arm_channel_distribution(self, distribution: str): + """Arm a distribution pattern to change at pattern end""" + if distribution in self.CHANNEL_DISTRIBUTION_PATTERNS: + self.armed_channel_distribution = distribution + self.armed_state_changed.emit() + + def clear_armed_channel_distribution(self): + """Clear armed distribution pattern""" + self.armed_channel_distribution = None + self.armed_state_changed.emit() + + def set_tempo(self, bpm: float): + """Set tempo in BPM""" + if 40 <= bpm <= 200: + self.tempo = bpm + self.calculate_step_duration() + self.tempo_changed.emit(bpm) + + def calculate_step_duration(self): + """Calculate time between steps based on tempo and note speed""" + beats_per_second = self.tempo / 60.0 + note_duration = self.NOTE_SPEEDS[self.note_speed] + self.step_duration = note_duration / beats_per_second + + def note_on(self, note: int): + """Register a note being pressed""" + self.held_notes.add(note) + self.update_input_chord() + self.regenerate_pattern() + + def note_off(self, note: int): + """Register a note being released""" + self.held_notes.discard(note) + self.update_input_chord() + if not self.held_notes: + self.stop() + else: + self.regenerate_pattern() + + def update_input_chord(self): + """Update the chord from held notes""" + self.input_chord = sorted(list(self.held_notes)) + + def start(self): + """Start arpeggiator playback""" + if not self.held_notes: + return False + + if not self.is_playing: + self.is_playing = True + self.current_step = 0 + self.pattern_position = 0 + self.last_step_time = time.time() + self.next_step_time = self.last_step_time + self.step_duration + self.playing_state_changed.emit(True) + return True + return False + + def stop(self): + """Stop arpeggiator playback""" + if self.is_playing: + self.is_playing = False + self.all_notes_off() + self.playing_state_changed.emit(False) + + def all_notes_off(self): + """Send note off for all active notes""" + current_time = time.time() + for (channel, note) in list(self.active_notes.keys()): + self.output_manager.send_note_off(channel, note) + self.channel_manager.release_voice(channel, note) + self.active_notes.clear() + + def regenerate_pattern(self): + """Regenerate arpeggio pattern based on current settings""" + if not self.input_chord: + self.current_pattern = [] + return + + # Get scale notes for the key + scale_intervals = self.SCALES[self.scale] + + # Generate pattern based on type + if self.pattern_type == "up": + self.current_pattern = self._generate_up_pattern() + elif self.pattern_type == "down": + self.current_pattern = self._generate_down_pattern() + elif self.pattern_type == "up_down": + self.current_pattern = self._generate_up_down_pattern() + elif self.pattern_type == "down_up": + self.current_pattern = self._generate_down_up_pattern() + elif self.pattern_type == "random": + self.current_pattern = self._generate_random_pattern() + elif self.pattern_type == "note_order": + self.current_pattern = self._generate_note_order_pattern() + elif self.pattern_type == "chord": + self.current_pattern = self._generate_chord_pattern() + elif self.pattern_type == "random_chord": + self.current_pattern = self._generate_random_chord_pattern() + + self.pattern_length = len(self.current_pattern) + self.pattern_position = 0 + + def _generate_scale_notes(self) -> List[int]: + """Generate all scale notes within octave range""" + scale_intervals = self.SCALES[self.scale] + notes = [] + + # Start from root note + base_octave = self.root_note // 12 + root_in_octave = self.root_note % 12 + + # Find closest scale degree to root + closest_degree = 0 + min_distance = 12 + for i, interval in enumerate(scale_intervals): + distance = abs((root_in_octave - interval) % 12) + if distance < min_distance: + min_distance = distance + closest_degree = i + + # Generate notes across octave range + for octave in range(self.octave_range): + for degree, interval in enumerate(scale_intervals): + note = base_octave * 12 + root_in_octave + interval + (octave * 12) + if 0 <= note <= 127: + notes.append(note) + + return sorted(notes) + + def _generate_up_pattern(self) -> List[int]: + """Generate ascending arpeggio pattern""" + scale_notes = self._generate_scale_notes() + return scale_notes + + def _generate_down_pattern(self) -> List[int]: + """Generate descending arpeggio pattern""" + scale_notes = self._generate_scale_notes() + return list(reversed(scale_notes)) + + def _generate_up_down_pattern(self) -> List[int]: + """Generate up then down pattern""" + scale_notes = self._generate_scale_notes() + # Up, then down (avoiding duplicate at top) + return scale_notes + list(reversed(scale_notes[:-1])) + + def _generate_down_up_pattern(self) -> List[int]: + """Generate down then up pattern""" + scale_notes = self._generate_scale_notes() + # Down, then up (avoiding duplicate at bottom) + return list(reversed(scale_notes)) + scale_notes[1:] + + def _generate_random_pattern(self) -> List[int]: + """Generate random pattern from scale notes""" + import random + scale_notes = self._generate_scale_notes() + pattern_length = max(8, len(scale_notes)) + return [random.choice(scale_notes) for _ in range(pattern_length)] + + def _generate_note_order_pattern(self) -> List[int]: + """Generate pattern in the order notes were played""" + return self.input_chord * self.octave_range + + def _generate_chord_pattern(self) -> List[int]: + """Generate chord pattern (all notes together, represented as sequence)""" + return self.input_chord + + def _generate_random_chord_pattern(self) -> List[int]: + """Generate pattern with random chord combinations""" + import random + import itertools + + if len(self.input_chord) < 2: + return self.input_chord + + # Generate various chord combinations + patterns = [] + + # Individual notes + patterns.extend(self.input_chord) + + # Pairs + for pair in itertools.combinations(self.input_chord, 2): + patterns.extend(pair) + + # Full chord + patterns.extend(self.input_chord) + + return patterns + + def _get_next_channel(self) -> int: + """Get next channel based on distribution pattern""" + active_channels = self.channel_manager.get_active_channels() + if not active_channels: + return 1 + + if self.channel_distribution == "single_channel": + return active_channels[0] + + elif self.channel_distribution == "up": + # 1, 2, 3, 4, 5, 6... + channel_idx = self.channel_position % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "down": + # 6, 5, 4, 3, 2, 1... + channel_idx = (len(active_channels) - 1 - self.channel_position) % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "up_down": + # 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3... + cycle_length = (len(active_channels) - 1) * 2 + pos = self.channel_position % cycle_length + + if pos < len(active_channels): + channel_idx = pos + else: + channel_idx = len(active_channels) - 2 - (pos - len(active_channels)) + + self.channel_position += 1 + return active_channels[max(0, channel_idx)] + + elif self.channel_distribution == "bounce": + # 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1, 2, 3... (same as up_down but clearer name) + return self._get_bounce_channel(active_channels) + + elif self.channel_distribution == "random": + import random + return random.choice(active_channels) + + elif self.channel_distribution == "cycle": + # Simple cycle through channels + channel_idx = self.channel_position % len(active_channels) + self.channel_position += 1 + return active_channels[channel_idx] + + elif self.channel_distribution == "alternating": + # Alternate between first and last, second and second-to-last, etc. + half_point = len(active_channels) // 2 + if self.channel_position % 2 == 0: + # Even steps: use first half + idx = (self.channel_position // 2) % half_point + else: + # Odd steps: use second half (from end) + idx = len(active_channels) - 1 - ((self.channel_position // 2) % half_point) + + self.channel_position += 1 + return active_channels[idx] + + # Default to up pattern + return active_channels[self.channel_position % len(active_channels)] + + def _get_bounce_channel(self, active_channels: List[int]) -> int: + """Get channel for bounce pattern with proper bounce logic""" + if len(active_channels) == 1: + return active_channels[0] + + # Create bounce sequence: 0,1,2,3,2,1,0,1,2,3... + bounce_length = (len(active_channels) - 1) * 2 + pos = self.channel_position % bounce_length + + if pos < len(active_channels): + # Going up: 0,1,2,3 + channel_idx = pos + else: + # Going down: 2,1,0 (skip the last one to avoid duplicate) + channel_idx = len(active_channels) - 2 - (pos - len(active_channels)) + + self.channel_position += 1 + return active_channels[max(0, min(len(active_channels) - 1, channel_idx))] + + def update(self): + """Main update loop - called frequently for timing precision""" + if not self.is_playing: + self.check_note_offs() + self.volume_engine.update_pattern(0.016) # ~60fps + return + + current_time = time.time() + + # Check if it's time for the next step + if current_time >= self.next_step_time and self.current_pattern: + self.process_step() + self.advance_step() + + # Check for notes to turn off + self.check_note_offs() + + # Update volume patterns + self.volume_engine.update_pattern(0.016) + + def process_step(self): + """Process the current arpeggio step""" + if not self.current_pattern: + return + + # Get note from pattern + note = self.current_pattern[self.pattern_position] + + # Apply swing + swing_offset = 0 + if self.swing != 0 and self.current_step % 2 == 1: + swing_offset = self.step_duration * self.swing * 0.1 + + # Route note to appropriate channel using distribution pattern + target_channel = self._get_next_channel() + + if target_channel: + # Use static velocity (not modified by volume patterns) + static_velocity = self.velocity + + # Calculate note duration + note_duration = self.step_duration * self.gate + note_end_time = time.time() + note_duration + swing_offset + + # Send note on + self.output_manager.send_note_on(target_channel, note, static_velocity) + + # Schedule note off + self.active_notes[(target_channel, note)] = note_end_time + + # Emit signal for GUI + self.note_triggered.emit(target_channel, note, static_velocity, note_duration) + + def advance_step(self): + """Advance to next step in pattern""" + old_pattern_position = self.pattern_position + self.pattern_position = (self.pattern_position + 1) % self.pattern_length + self.current_step += 1 + + # Check if pattern completed a full loop + if old_pattern_position != 0 and self.pattern_position == 0: + self.pattern_loops_completed += 1 + self.apply_armed_changes() + + # Calculate next step time with swing + base_time = self.next_step_time + self.step_duration + + # Apply swing to next step if it's an off-beat + if self.swing != 0 and (self.current_step + 1) % 2 == 1: + swing_offset = self.step_duration * self.swing * 0.1 + self.next_step_time = base_time + swing_offset + else: + self.next_step_time = base_time + + self.pattern_step.emit(self.current_step) + + def apply_armed_changes(self): + """Apply armed changes at pattern end""" + changes_applied = False + + # Apply armed root note + if self.armed_root_note is not None: + self.root_note = self.armed_root_note + self.armed_root_note = None + changes_applied = True + + # Apply armed scale + if self.armed_scale is not None: + self.scale = self.armed_scale + self.armed_scale = None + changes_applied = True + + # Apply armed pattern type + if self.armed_pattern_type is not None: + self.pattern_type = self.armed_pattern_type + self.armed_pattern_type = None + changes_applied = True + + # Apply armed channel distribution + if self.armed_channel_distribution is not None: + self.channel_distribution = self.armed_channel_distribution + self.channel_position = 0 # Reset position + self.armed_channel_distribution = None + changes_applied = True + + # If any changes were applied, regenerate pattern and emit signal + if changes_applied: + self.regenerate_pattern() + self.armed_state_changed.emit() + + def check_note_offs(self): + """Check for notes that should be turned off""" + current_time = time.time() + notes_to_remove = [] + + for (channel, note), end_time in self.active_notes.items(): + if current_time >= end_time: + self.output_manager.send_note_off(channel, note) + self.channel_manager.release_voice(channel, note) + notes_to_remove.append((channel, note)) + + for key in notes_to_remove: + del self.active_notes[key] + + def get_current_state(self) -> Dict: + """Get current arpeggiator state""" + return { + 'is_playing': self.is_playing, + 'root_note': self.root_note, + 'scale': self.scale, + 'pattern_type': self.pattern_type, + 'octave_range': self.octave_range, + 'note_speed': self.note_speed, + 'gate': self.gate, + 'swing': self.swing, + 'velocity': self.velocity, + 'tempo': self.tempo, + 'current_step': self.current_step, + 'pattern_position': self.pattern_position, + 'pattern_length': self.pattern_length, + 'held_notes': list(self.held_notes), + 'current_pattern': self.current_pattern.copy() + } \ No newline at end of file diff --git a/core/midi_channel_manager.py b/core/midi_channel_manager.py new file mode 100644 index 0000000..549a945 --- /dev/null +++ b/core/midi_channel_manager.py @@ -0,0 +1,183 @@ +""" +MIDI Channel Manager Module + +Handles MIDI channel management, instrument assignments, and voice allocation. +Manages up to 16 synth channels with individual program changes and polyphonic voice tracking. +""" + +import mido +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +class MIDIChannelManager(QObject): + """ + Manages MIDI channels, instruments, and voice allocation for multiple synths. + Each synth corresponds to a MIDI channel (1-16). + """ + + # Signals for GUI updates + active_synth_count_changed = pyqtSignal(int) + channel_instrument_changed = pyqtSignal(int, int) # channel, program + voice_allocation_changed = pyqtSignal(int, list) # channel, active_notes + + # General MIDI Program Names (first 128) + GM_PROGRAMS = [ + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", + "Violin", "Viola", "Cello", "Contrabass", + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", + "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", + "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", + "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", + "Oboe", "English Horn", "Bassoon", "Clarinet", + "Piccolo", "Flute", "Recorder", "Pan Flute", + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", + "Sitar", "Banjo", "Shamisen", "Koto", + "Kalimba", "Bag pipe", "Fiddle", "Shanai", + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", + "Telephone Ring", "Helicopter", "Applause", "Gunshot" + ] + + def __init__(self): + super().__init__() + self.active_synth_count = 8 # Default to 8 synths + self.max_synths = 16 + self.max_voices_per_synth = 3 + + # Channel instruments (1-based channel numbering) + self.channel_instruments: Dict[int, int] = {i: 0 for i in range(1, 17)} # Default to Piano + + # Voice allocation tracking + self.active_voices: Dict[int, List[int]] = {i: [] for i in range(1, 17)} # {channel: [note1, note2, note3]} + + def set_active_synth_count(self, count: int) -> bool: + """Set the number of active synths (1-16)""" + if 1 <= count <= self.max_synths: + self.active_synth_count = count + self.active_synth_count_changed.emit(count) + return True + return False + + def get_active_channels(self) -> List[int]: + """Get list of currently active channel numbers""" + return list(range(1, self.active_synth_count + 1)) + + def set_channel_instrument(self, channel: int, program: int) -> bool: + """Set instrument program for a specific channel""" + if 1 <= channel <= self.max_synths and 0 <= program <= 127: + self.channel_instruments[channel] = program + self.channel_instrument_changed.emit(channel, program) + return True + return False + + def set_all_instruments(self, program: int) -> bool: + """Set the same instrument for all active channels""" + if 0 <= program <= 127: + for channel in self.get_active_channels(): + self.channel_instruments[channel] = program + self.channel_instrument_changed.emit(channel, program) + return True + return False + + def get_channel_instrument(self, channel: int) -> Optional[int]: + """Get current instrument program for a channel""" + if 1 <= channel <= self.max_synths: + return self.channel_instruments.get(channel, 0) + return None + + def get_instrument_name(self, program: int) -> str: + """Get human-readable name for a GM program number""" + if 0 <= program < len(self.GM_PROGRAMS): + return self.GM_PROGRAMS[program] + return f"Program {program}" + + def allocate_voice(self, channel: int, note: int) -> bool: + """ + Allocate a voice for a note on a channel. + Returns True if voice was allocated, False if channel is full. + Implements voice stealing if necessary. + """ + if channel not in self.active_voices: + return False + + voices = self.active_voices[channel] + + # If note is already playing, don't allocate again + if note in voices: + return True + + # If we have space, just add it + if len(voices) < self.max_voices_per_synth: + voices.append(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + + # Voice stealing: remove the oldest note and add the new one + voices.pop(0) # Remove oldest + voices.append(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + + def release_voice(self, channel: int, note: int) -> bool: + """Release a voice for a note on a channel""" + if channel not in self.active_voices: + return False + + voices = self.active_voices[channel] + if note in voices: + voices.remove(note) + self.voice_allocation_changed.emit(channel, voices.copy()) + return True + return False + + def get_active_voices(self, channel: int) -> List[int]: + """Get list of currently active notes for a channel""" + return self.active_voices.get(channel, []).copy() + + def is_voice_available(self, channel: int) -> bool: + """Check if a channel has available voice slots""" + if channel not in self.active_voices: + return False + return len(self.active_voices[channel]) < self.max_voices_per_synth + + def get_voice_count(self, channel: int) -> int: + """Get current number of active voices for a channel""" + return len(self.active_voices.get(channel, [])) + + def clear_all_voices(self): + """Clear all active voices on all channels""" + for channel in range(1, self.max_synths + 1): + if self.active_voices[channel]: + self.active_voices[channel].clear() + self.voice_allocation_changed.emit(channel, []) + + def get_channel_status(self) -> Dict[int, Dict]: + """Get comprehensive status for all channels""" + status = {} + for channel in range(1, self.active_synth_count + 1): + status[channel] = { + 'instrument': self.channel_instruments[channel], + 'instrument_name': self.get_instrument_name(self.channel_instruments[channel]), + 'active_voices': self.active_voices[channel].copy(), + 'voice_count': len(self.active_voices[channel]), + 'voices_available': self.max_voices_per_synth - len(self.active_voices[channel]) + } + return status \ No newline at end of file diff --git a/core/output_manager.py b/core/output_manager.py new file mode 100644 index 0000000..3f90cc6 --- /dev/null +++ b/core/output_manager.py @@ -0,0 +1,336 @@ +""" +Output Manager Module + +Manages MIDI output routing between simulator mode and hardware mode. +Handles program changes, note on/off messages, and volume control. +""" + +import time +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +# Try to import MIDI libraries with fallbacks +try: + import mido + MIDO_AVAILABLE = True +except ImportError: + print("Warning: mido not available, creating fallback") + MIDO_AVAILABLE = False + +try: + import rtmidi + RTMIDI_AVAILABLE = True +except ImportError: + print("Warning: rtmidi not available") + RTMIDI_AVAILABLE = False + +# Fallback MIDI message class if mido not available +if not MIDO_AVAILABLE: + class Message: + def __init__(self, msg_type, **kwargs): + self.type = msg_type + self.__dict__.update(kwargs) + + def bytes(self): + if self.type == 'note_on': + return [0x90 | self.channel, self.note, self.velocity] + elif self.type == 'note_off': + return [0x80 | self.channel, self.note, self.velocity] + elif self.type == 'program_change': + return [0xC0 | self.channel, self.program] + elif self.type == 'control_change': + return [0xB0 | self.channel, self.control, self.value] + return [] + + # Create a mock mido module + class MockMido: + Message = Message + @staticmethod + def get_output_names(): + return ["No MIDI - Simulator Only"] + @staticmethod + def open_output(name): + return None + + mido = MockMido() + +class OutputManager(QObject): + """ + Manages MIDI output to either hardware devices or internal simulator. + Provides seamless switching between modes and handles all MIDI communication. + """ + + # Signals + mode_changed = pyqtSignal(str) # "simulator" or "hardware" + midi_device_changed = pyqtSignal(str) # device name + note_sent = pyqtSignal(int, int, int, bool) # channel, note, velocity, is_note_on + program_sent = pyqtSignal(int, int) # channel, program + volume_sent = pyqtSignal(int, int) # channel, volume + error_occurred = pyqtSignal(str) # error message + + def __init__(self, simulator_engine=None): + super().__init__() + + # Mode selection + self.current_mode = "simulator" # "simulator" or "hardware" + self.simulator_engine = simulator_engine + + # Hardware MIDI + self.midi_output = None + self.available_outputs = [] + self.selected_output = None + + # Channel volumes (CC7) + self.channel_volumes: Dict[int, int] = {i: 100 for i in range(1, 17)} + + # Program changes + self.channel_programs: Dict[int, int] = {i: 0 for i in range(1, 17)} + + # Initialize MIDI + self.refresh_midi_devices() + + def refresh_midi_devices(self): + """Refresh list of available MIDI output devices""" + try: + self.available_outputs = mido.get_output_names() + except Exception as e: + self.error_occurred.emit(f"Error refreshing MIDI devices: {str(e)}") + self.available_outputs = [] + + def get_available_outputs(self) -> List[str]: + """Get list of available MIDI output device names""" + return self.available_outputs.copy() + + def set_mode(self, mode: str) -> bool: + """Set output mode: 'simulator' or 'hardware'""" + if mode in ["simulator", "hardware"]: + old_mode = self.current_mode + self.current_mode = mode + + if mode == "hardware" and old_mode == "simulator": + # Switching to hardware - sync current state + self._sync_to_hardware() + elif mode == "simulator" and old_mode == "hardware": + # Switching to simulator - stop all hardware notes + self._all_notes_off_hardware() + + self.mode_changed.emit(mode) + return True + return False + + def set_midi_output(self, device_name: str) -> bool: + """Set hardware MIDI output device""" + if device_name not in self.available_outputs: + return False + + # Close existing connection + if self.midi_output: + try: + self.midi_output.close() + except: + pass + self.midi_output = None + + # Open new connection + try: + self.midi_output = mido.open_output(device_name) + self.selected_output = device_name + self.midi_device_changed.emit(device_name) + + # Sync current state to new device + if self.current_mode == "hardware": + self._sync_to_hardware() + + return True + except Exception as e: + self.error_occurred.emit(f"Error opening MIDI device {device_name}: {str(e)}") + return False + + def send_note_on(self, channel: int, note: int, velocity: int): + """Send note on message""" + if not (1 <= channel <= 16 and 0 <= note <= 127 and 0 <= velocity <= 127): + return + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.play_note(channel, note, velocity) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('note_on', channel=channel-1, note=note, velocity=velocity) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending note on: {str(e)}") + + # Always update simulator for visual feedback, regardless of mode + if self.simulator_engine: + self.simulator_engine.update_lighting(channel, velocity, velocity / 127.0) + + self.note_sent.emit(channel, note, velocity, True) + + def send_note_off(self, channel: int, note: int): + """Send note off message""" + if not (1 <= channel <= 16 and 0 <= note <= 127): + return + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.stop_note(channel, note) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('note_off', channel=channel-1, note=note, velocity=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending note off: {str(e)}") + + # Always update simulator for visual feedback, regardless of mode + if self.simulator_engine: + self.simulator_engine.fade_lighting(channel) + + self.note_sent.emit(channel, note, 0, False) + + def send_program_change(self, channel: int, program: int): + """Send program change message""" + if not (1 <= channel <= 16 and 0 <= program <= 127): + return + + self.channel_programs[channel] = program + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.change_program(channel, program) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('program_change', channel=channel-1, program=program) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending program change: {str(e)}") + + self.program_sent.emit(channel, program) + + def send_volume_change(self, channel: int, volume: int): + """Send channel volume change (CC7)""" + if not (1 <= channel <= 16 and 0 <= volume <= 127): + return + + self.channel_volumes[channel] = volume + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.set_channel_volume(channel, volume) + elif self.current_mode == "hardware" and self.midi_output: + try: + msg = mido.Message('control_change', channel=channel-1, control=7, value=volume) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending volume change: {str(e)}") + + self.volume_sent.emit(channel, volume) + + def send_all_notes_off(self, channel: int = None): + """Send all notes off message""" + channels = [channel] if channel else range(1, 17) + + for ch in channels: + if not (1 <= ch <= 16): + continue + + if self.current_mode == "simulator" and self.simulator_engine: + self.simulator_engine.all_notes_off(ch) + elif self.current_mode == "hardware" and self.midi_output: + try: + # Send CC 123 (All Notes Off) + msg = mido.Message('control_change', channel=ch-1, control=123, value=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error sending all notes off: {str(e)}") + + def send_panic(self): + """Send panic message to all channels""" + if self.current_mode == "hardware" and self.midi_output: + try: + for channel in range(16): + # All Sound Off (CC 120) + msg1 = mido.Message('control_change', channel=channel, control=120, value=0) + self.midi_output.send(msg1) + + # All Notes Off (CC 123) + msg2 = mido.Message('control_change', channel=channel, control=123, value=0) + self.midi_output.send(msg2) + + # Reset All Controllers (CC 121) + msg3 = mido.Message('control_change', channel=channel, control=121, value=0) + self.midi_output.send(msg3) + except Exception as e: + self.error_occurred.emit(f"Error sending panic: {str(e)}") + elif self.simulator_engine: + self.simulator_engine.panic() + + def _sync_to_hardware(self): + """Sync current program and volume settings to hardware""" + if not (self.current_mode == "hardware" and self.midi_output): + return + + try: + # Send current program changes + for channel, program in self.channel_programs.items(): + if 1 <= channel <= 16: + msg = mido.Message('program_change', channel=channel-1, program=program) + self.midi_output.send(msg) + time.sleep(0.001) # Small delay between messages + + # Send current volume settings + for channel, volume in self.channel_volumes.items(): + if 1 <= channel <= 16: + msg = mido.Message('control_change', channel=channel-1, control=7, value=volume) + self.midi_output.send(msg) + time.sleep(0.001) + + except Exception as e: + self.error_occurred.emit(f"Error syncing to hardware: {str(e)}") + + def _all_notes_off_hardware(self): + """Send all notes off to hardware when switching away""" + if self.midi_output: + try: + for channel in range(16): + msg = mido.Message('control_change', channel=channel, control=123, value=0) + self.midi_output.send(msg) + except Exception as e: + self.error_occurred.emit(f"Error turning off hardware notes: {str(e)}") + + def get_channel_volume(self, channel: int) -> int: + """Get current volume for a channel""" + return self.channel_volumes.get(channel, 100) + + def get_channel_program(self, channel: int) -> int: + """Get current program for a channel""" + return self.channel_programs.get(channel, 0) + + def is_connected(self) -> bool: + """Check if output is properly connected""" + if self.current_mode == "simulator": + return self.simulator_engine is not None + elif self.current_mode == "hardware": + return self.midi_output is not None + return False + + def get_status_info(self) -> Dict: + """Get comprehensive status information""" + return { + 'mode': self.current_mode, + 'connected': self.is_connected(), + 'selected_output': self.selected_output, + 'available_outputs': self.available_outputs.copy(), + 'channel_volumes': self.channel_volumes.copy(), + 'channel_programs': self.channel_programs.copy() + } + + def close(self): + """Close MIDI connection""" + if self.midi_output: + try: + self._all_notes_off_hardware() + self.midi_output.close() + except: + pass + self.midi_output = None + + def __del__(self): + """Cleanup on destruction""" + self.close() \ No newline at end of file diff --git a/core/synth_router.py b/core/synth_router.py new file mode 100644 index 0000000..ff69bb5 --- /dev/null +++ b/core/synth_router.py @@ -0,0 +1,352 @@ +""" +Synth Router Module + +Routes MIDI notes to appropriate synth channels based on arpeggio patterns. +Handles spatial routing patterns for lighting effects and musical distribution. +""" + +import math +import random +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +from .midi_channel_manager import MIDIChannelManager + +class SynthRouter(QObject): + """ + Routes notes to synth channels based on musical and spatial patterns. + Integrates with MIDIChannelManager for voice allocation and channel management. + """ + + # Signals + note_routed = pyqtSignal(int, int, int) # channel, note, velocity + pattern_changed = pyqtSignal(str) + + # Routing pattern types + ROUTING_PATTERNS = [ + # Musical patterns + "single_synth", "round_robin", "chord_spread", "random_musical", + + # Spatial/lighting patterns + "bounce", "cycle", "wave", "ripple", "cascade", + "random_spatial", "spotlight", "alternating", "center_out", + "spiral", "zigzag" + ] + + def __init__(self, channel_manager: MIDIChannelManager): + super().__init__() + self.channel_manager = channel_manager + + # Current routing settings + self.current_pattern = "single_synth" + self.primary_channel = 1 # For single synth mode + + # Pattern state tracking + self.pattern_position = 0 + self.pattern_direction = 1 # 1 for forward, -1 for reverse + self.last_channels = [] # Track recent channel usage + + # Bounce pattern specific + self.bounce_position = 0 + self.bounce_direction = 1 + + # Cycle pattern specific + self.cycle_position = 0 + + # Random state + self.random_weights = {} # Channel preference weights + + def set_routing_pattern(self, pattern_name: str) -> bool: + """Set the current routing pattern""" + if pattern_name in self.ROUTING_PATTERNS: + self.current_pattern = pattern_name + self.reset_pattern_state() + self.pattern_changed.emit(pattern_name) + return True + return False + + def set_primary_channel(self, channel: int): + """Set primary channel for single synth mode""" + if 1 <= channel <= 16: + self.primary_channel = channel + + def reset_pattern_state(self): + """Reset pattern-specific state variables""" + self.pattern_position = 0 + self.pattern_direction = 1 + self.bounce_position = 0 + self.bounce_direction = 1 + self.cycle_position = 0 + self.last_channels.clear() + + # Initialize random weights + active_channels = self.channel_manager.get_active_channels() + for channel in active_channels: + self.random_weights[channel] = 1.0 + + def route_note(self, note: int, velocity: int, chord_notes: List[int] = None) -> Optional[int]: + """ + Route a note to the appropriate synth channel based on current pattern. + Returns the selected channel number, or None if routing failed. + """ + active_channels = self.channel_manager.get_active_channels() + if not active_channels: + return None + + # Handle chord spreading for specific patterns + if chord_notes and len(chord_notes) > 1 and self.current_pattern == "chord_spread": + return self._route_chord_spread(note, velocity, chord_notes) + + # Route single note based on pattern + target_channel = self._select_target_channel(note, active_channels) + + if target_channel: + # Check voice availability and allocate + if self.channel_manager.allocate_voice(target_channel, note): + self.note_routed.emit(target_channel, note, velocity) + self._update_pattern_state(target_channel, active_channels) + return target_channel + + return None + + def _select_target_channel(self, note: int, active_channels: List[int]) -> Optional[int]: + """Select target channel based on current routing pattern""" + + if self.current_pattern == "single_synth": + return self.primary_channel if self.primary_channel in active_channels else active_channels[0] + + elif self.current_pattern == "round_robin": + return self._round_robin_select(active_channels) + + elif self.current_pattern == "random_musical": + return random.choice(active_channels) + + elif self.current_pattern == "bounce": + return self._bounce_select(active_channels) + + elif self.current_pattern == "cycle": + return self._cycle_select(active_channels) + + elif self.current_pattern == "wave": + return self._wave_select(active_channels) + + elif self.current_pattern == "ripple": + return self._ripple_select(active_channels) + + elif self.current_pattern == "cascade": + return self._cascade_select(active_channels) + + elif self.current_pattern == "random_spatial": + return self._weighted_random_select(active_channels) + + elif self.current_pattern == "spotlight": + return self._spotlight_select(active_channels) + + elif self.current_pattern == "alternating": + return self._alternating_select(active_channels) + + elif self.current_pattern == "center_out": + return self._center_out_select(active_channels) + + elif self.current_pattern == "spiral": + return self._spiral_select(active_channels) + + elif self.current_pattern == "zigzag": + return self._zigzag_select(active_channels) + + # Default fallback + return active_channels[0] + + def _round_robin_select(self, active_channels: List[int]) -> int: + """Simple round-robin through channels""" + channel = active_channels[self.pattern_position % len(active_channels)] + return channel + + def _bounce_select(self, active_channels: List[int]) -> int: + """Bounce back and forth between first and last channels""" + if len(active_channels) == 1: + return active_channels[0] + + # Calculate bounce position + if self.bounce_direction == 1: + if self.bounce_position >= len(active_channels) - 1: + self.bounce_direction = -1 + else: + if self.bounce_position <= 0: + self.bounce_direction = 1 + + return active_channels[self.bounce_position] + + def _cycle_select(self, active_channels: List[int]) -> int: + """Cycle through channels in order""" + channel = active_channels[self.cycle_position % len(active_channels)] + return channel + + def _wave_select(self, active_channels: List[int]) -> int: + """Wave pattern across channels""" + wave_position = math.sin(self.pattern_position * 0.5) * 0.5 + 0.5 + channel_index = int(wave_position * (len(active_channels) - 1)) + return active_channels[channel_index] + + def _ripple_select(self, active_channels: List[int]) -> int: + """Ripple effect from center outward""" + center = len(active_channels) // 2 + ripple_radius = (self.pattern_position // 2) % (len(active_channels) // 2 + 1) + + # Select channels at current ripple radius + candidates = [] + for i, channel in enumerate(active_channels): + distance = abs(i - center) + if distance == ripple_radius: + candidates.append(channel) + + return random.choice(candidates) if candidates else active_channels[center] + + def _cascade_select(self, active_channels: List[int]) -> int: + """Cascade effect - sequential with overlap""" + cascade_width = 3 # Number of channels in cascade + cascade_position = self.pattern_position % (len(active_channels) + cascade_width) + + # Find channels in current cascade window + candidates = [] + for i in range(cascade_width): + idx = (cascade_position - i) % len(active_channels) + if idx >= 0: + candidates.append(active_channels[idx]) + + return random.choice(candidates) if candidates else active_channels[0] + + def _weighted_random_select(self, active_channels: List[int]) -> int: + """Random selection with dynamic weights""" + # Adjust weights to avoid recently used channels + for channel in self.last_channels[-3:]: # Last 3 channels get lower weight + if channel in self.random_weights: + self.random_weights[channel] *= 0.5 + + # Normalize weights + total_weight = sum(self.random_weights.get(ch, 1.0) for ch in active_channels) + if total_weight <= 0: + return random.choice(active_channels) + + # Weighted random selection + rand_val = random.random() * total_weight + cumulative = 0 + for channel in active_channels: + cumulative += self.random_weights.get(channel, 1.0) + if rand_val <= cumulative: + return channel + + return active_channels[-1] # Fallback + + def _spotlight_select(self, active_channels: List[int]) -> int: + """Spotlight effect - focus on one channel at a time""" + spotlight_duration = 8 # Notes per spotlight + spotlight_channel_idx = (self.pattern_position // spotlight_duration) % len(active_channels) + return active_channels[spotlight_channel_idx] + + def _alternating_select(self, active_channels: List[int]) -> int: + """Alternate between even and odd channels""" + if len(active_channels) < 2: + return active_channels[0] + + if self.pattern_position % 2 == 0: + # Even positions - select from first half + half = len(active_channels) // 2 + return active_channels[self.pattern_position // 2 % (half if half > 0 else 1)] + else: + # Odd positions - select from second half + half = len(active_channels) // 2 + second_half = active_channels[half:] if half > 0 else active_channels + return second_half[(self.pattern_position // 2) % len(second_half)] + + def _center_out_select(self, active_channels: List[int]) -> int: + """Select from center outward""" + center = len(active_channels) // 2 + radius = (self.pattern_position // 2) % (len(active_channels) // 2 + 1) + + # Alternate between left and right of center + if self.pattern_position % 2 == 0: + idx = center + radius + else: + idx = center - radius + + idx = max(0, min(len(active_channels) - 1, idx)) + return active_channels[idx] + + def _spiral_select(self, active_channels: List[int]) -> int: + """Spiral pattern through channels""" + # Create spiral by varying step size + spiral_step = 2 if len(active_channels) > 4 else 1 + idx = (self.pattern_position * spiral_step) % len(active_channels) + return active_channels[idx] + + def _zigzag_select(self, active_channels: List[int]) -> int: + """Zigzag pattern through channels""" + period = len(active_channels) * 2 - 2 + position = self.pattern_position % period + + if position < len(active_channels): + idx = position + else: + idx = len(active_channels) - 2 - (position - len(active_channels)) + + idx = max(0, min(len(active_channels) - 1, idx)) + return active_channels[idx] + + def _route_chord_spread(self, note: int, velocity: int, chord_notes: List[int]) -> Optional[int]: + """Spread chord notes across different channels""" + active_channels = self.channel_manager.get_active_channels() + + # Find position of current note in chord + try: + note_index = chord_notes.index(note) + except ValueError: + note_index = 0 + + # Distribute chord notes across channels + if len(chord_notes) <= len(active_channels): + # Enough channels for each note + target_channel = active_channels[note_index % len(active_channels)] + else: + # More notes than channels - use round robin + target_channel = active_channels[note_index % len(active_channels)] + + return target_channel + + def _update_pattern_state(self, selected_channel: int, active_channels: List[int]): + """Update pattern state after routing a note""" + self.pattern_position += 1 + + # Track recent channels + self.last_channels.append(selected_channel) + if len(self.last_channels) > 5: + self.last_channels.pop(0) + + # Update bounce position + if self.current_pattern == "bounce": + self.bounce_position += self.bounce_direction + if self.bounce_position >= len(active_channels) - 1: + self.bounce_direction = -1 + elif self.bounce_position <= 0: + self.bounce_direction = 1 + + # Update cycle position + elif self.current_pattern == "cycle": + self.cycle_position += 1 + + # Restore random weights gradually + for channel in active_channels: + if channel in self.random_weights: + self.random_weights[channel] = min(1.0, self.random_weights[channel] + 0.1) + + def get_pattern_info(self) -> Dict: + """Get current pattern state information""" + return { + 'pattern': self.current_pattern, + 'position': self.pattern_position, + 'primary_channel': self.primary_channel, + 'bounce_position': self.bounce_position, + 'bounce_direction': self.bounce_direction, + 'cycle_position': self.cycle_position, + 'recent_channels': self.last_channels.copy() + } \ No newline at end of file diff --git a/core/volume_pattern_engine.py b/core/volume_pattern_engine.py new file mode 100644 index 0000000..caf9708 --- /dev/null +++ b/core/volume_pattern_engine.py @@ -0,0 +1,280 @@ +""" +Volume Pattern Engine Module + +Handles volume and velocity pattern generation for visual lighting effects. +Creates dynamic volume patterns that control both audio levels and lighting brightness. +""" + +import math +import random +from typing import Dict, List, Tuple, Optional +from PyQt5.QtCore import QObject, pyqtSignal + +class VolumePatternEngine(QObject): + """ + Generates volume and velocity patterns for enhanced visual effects. + Controls both MIDI channel volume (CC7) and note velocity for brightness control. + """ + + # Signals for GUI updates + pattern_changed = pyqtSignal(str) # pattern_name + volume_updated = pyqtSignal(int, float) # channel, volume (0.0-1.0) + + # Pattern types available + PATTERN_TYPES = [ + "static", "swell", "breathing", "wave", "build", "fade", + "pulse", "alternating", "stutter", "cascade", "ripple", + "random_sparkle", "spotlight", "bounce_volume" + ] + + def __init__(self): + super().__init__() + + # Current pattern settings + self.current_pattern = "static" + self.pattern_speed = 1.0 # Speed multiplier + self.pattern_intensity = 1.0 # Intensity multiplier + + # Position tracking for patterns + self.pattern_position = 0.0 + self.pattern_direction = 1 # 1 for forward, -1 for reverse + + # Volume ranges per channel {channel: (min, max)} + self.channel_volume_ranges: Dict[int, Tuple[float, float]] = {} + self.velocity_ranges: Dict[int, Tuple[int, int]] = {} + + # Global ranges (applied to all channels if no individual range set) + self.global_volume_range = (0.2, 1.0) # 20% to 100% + self.global_velocity_range = (40, 127) # MIDI velocity range + + # Pattern state + self.pattern_phases: Dict[int, float] = {} # Per-channel phase offsets + self.random_states: Dict[int, float] = {} # For random patterns + + # Initialize random states for channels + for channel in range(1, 17): + self.pattern_phases[channel] = random.random() * 2 * math.pi + self.random_states[channel] = random.random() + + def set_pattern(self, pattern_name: str) -> bool: + """Set the current volume pattern""" + if pattern_name in self.PATTERN_TYPES: + self.current_pattern = pattern_name + self.pattern_changed.emit(pattern_name) + return True + return False + + def set_pattern_speed(self, speed: float): + """Set pattern speed multiplier (0.1 to 5.0)""" + self.pattern_speed = max(0.1, min(5.0, speed)) + + def set_pattern_intensity(self, intensity: float): + """Set pattern intensity multiplier (0.0 to 2.0)""" + self.pattern_intensity = max(0.0, min(2.0, intensity)) + + def set_channel_volume_range(self, channel: int, min_vol: float, max_vol: float): + """Set volume range for a specific channel (0.0 to 1.0)""" + min_vol = max(0.0, min(1.0, min_vol)) + max_vol = max(min_vol, min(1.0, max_vol)) + self.channel_volume_ranges[channel] = (min_vol, max_vol) + + def set_velocity_range(self, channel: int, min_vel: int, max_vel: int): + """Set velocity range for a specific channel (0-127)""" + min_vel = max(0, min(127, min_vel)) + max_vel = max(min_vel, min(127, max_vel)) + self.velocity_ranges[channel] = (min_vel, max_vel) + + def set_global_ranges(self, min_vol: float, max_vol: float, min_vel: int, max_vel: int): + """Set global volume and velocity ranges""" + self.global_volume_range = (max(0.0, min(1.0, min_vol)), max(0.0, min(1.0, max_vol))) + self.global_velocity_range = (max(0, min(127, min_vel)), max(0, min(127, max_vel))) + + def get_channel_volume_range(self, channel: int) -> Tuple[float, float]: + """Get volume range for a channel (uses global if not set individually)""" + return self.channel_volume_ranges.get(channel, self.global_volume_range) + + def get_velocity_range(self, channel: int) -> Tuple[int, int]: + """Get velocity range for a channel (uses global if not set individually)""" + return self.velocity_ranges.get(channel, self.global_velocity_range) + + def update_pattern(self, delta_time: float): + """Update pattern position based on elapsed time""" + self.pattern_position += delta_time * self.pattern_speed + + def get_channel_volume(self, channel: int, active_channel_count: int = 8) -> float: + """ + Calculate current volume for a channel based on active pattern. + Returns volume as float 0.0 to 1.0 + """ + min_vol, max_vol = self.get_channel_volume_range(channel) + + # Get base pattern value (0.0 to 1.0) + pattern_value = self._calculate_pattern_value(channel, active_channel_count) + + # Apply intensity + pattern_value = pattern_value * self.pattern_intensity + pattern_value = max(0.0, min(1.0, pattern_value)) + + # Scale to channel's volume range + volume = min_vol + (max_vol - min_vol) * pattern_value + + return volume + + def get_note_velocity(self, base_velocity: int, channel: int, active_channel_count: int = 8) -> int: + """ + Calculate note velocity based on volume pattern and base velocity. + Returns MIDI velocity (0-127) + """ + min_vel, max_vel = self.get_velocity_range(channel) + + # Get pattern influence (0.0 to 1.0) + pattern_value = self._calculate_pattern_value(channel, active_channel_count) + pattern_value = pattern_value * self.pattern_intensity + pattern_value = max(0.0, min(1.0, pattern_value)) + + # Blend base velocity with pattern + pattern_velocity = min_vel + (max_vel - min_vel) * pattern_value + + # Combine with base velocity (weighted average) + final_velocity = int((base_velocity + pattern_velocity) / 2) + + return max(0, min(127, final_velocity)) + + def _calculate_pattern_value(self, channel: int, active_channel_count: int) -> float: + """Calculate raw pattern value (0.0 to 1.0) for a channel""" + + if self.current_pattern == "static": + return 1.0 + + elif self.current_pattern == "swell": + # Gradual swell up and down + cycle = math.sin(self.pattern_position * 0.5) * 0.5 + 0.5 + return cycle + + elif self.current_pattern == "breathing": + # Smooth breathing rhythm + cycle = math.sin(self.pattern_position) * 0.5 + 0.5 + return 0.3 + cycle * 0.7 # Keep minimum at 30% + + elif self.current_pattern == "wave": + # Sine wave across channels + phase_offset = (channel - 1) * (2 * math.pi / active_channel_count) + wave = math.sin(self.pattern_position + phase_offset) * 0.5 + 0.5 + return wave + + elif self.current_pattern == "build": + # Gradual crescendo + build_progress = (self.pattern_position * 0.1) % 2.0 + if build_progress > 1.0: + build_progress = 2.0 - build_progress # Fade back down + return build_progress + + elif self.current_pattern == "fade": + # Gradual diminuendo + fade_progress = 1.0 - ((self.pattern_position * 0.1) % 1.0) + return fade_progress + + elif self.current_pattern == "pulse": + # Sharp rhythmic pulses + pulse = math.sin(self.pattern_position * 2) + return 1.0 if pulse > 0.8 else 0.3 + + elif self.current_pattern == "alternating": + # Alternate between high and low + return 1.0 if int(self.pattern_position) % 2 == 0 else 0.3 + + elif self.current_pattern == "stutter": + # Rapid volume changes + stutter = math.sin(self.pattern_position * 8) * 0.5 + 0.5 + return stutter + + elif self.current_pattern == "cascade": + # Volume cascades across channels + return self._cascade_pattern(channel, active_channel_count) + + elif self.current_pattern == "ripple": + # Ripple effect from center + return self._ripple_pattern(channel, active_channel_count) + + elif self.current_pattern == "random_sparkle": + # Random sparkle effect + return self._random_sparkle_pattern(channel) + + elif self.current_pattern == "spotlight": + # Spotlight effect - one channel bright, others dim + return self._spotlight_pattern(channel, active_channel_count) + + elif self.current_pattern == "bounce_volume": + # Volume follows bounce pattern + return self._bounce_volume_pattern(channel, active_channel_count) + + return 1.0 # Default fallback + + def _cascade_pattern(self, channel: int, active_channel_count: int) -> float: + """Volume cascade across channels""" + cascade_position = (self.pattern_position * 0.5) % active_channel_count + distance = min( + abs(channel - 1 - cascade_position), + active_channel_count - abs(channel - 1 - cascade_position) + ) + return max(0.2, 1.0 - (distance / active_channel_count) * 0.8) + + def _ripple_pattern(self, channel: int, active_channel_count: int) -> float: + """Ripple effect from center""" + center = active_channel_count / 2 + distance = abs(channel - 1 - center) + ripple_phase = self.pattern_position - distance * 0.5 + ripple = math.sin(ripple_phase) * 0.5 + 0.5 + return max(0.2, ripple) + + def _random_sparkle_pattern(self, channel: int) -> float: + """Random sparkle effect""" + # Update random state periodically + if int(self.pattern_position * 4) % 8 == 0: + self.random_states[channel] = random.random() + + base_random = self.random_states[channel] + sparkle_threshold = 0.7 + + if base_random > sparkle_threshold: + # Sparkle! Add some randomness to timing + sparkle_intensity = (base_random - sparkle_threshold) / (1.0 - sparkle_threshold) + return 0.3 + sparkle_intensity * 0.7 + else: + return 0.2 + base_random * 0.3 + + def _spotlight_pattern(self, channel: int, active_channel_count: int) -> float: + """Spotlight effect - one channel bright, others dim""" + spotlight_channel = int(self.pattern_position * 0.3) % active_channel_count + 1 + if channel == spotlight_channel: + return 1.0 + else: + return 0.2 + + def _bounce_volume_pattern(self, channel: int, active_channel_count: int) -> float: + """Volume follows bounce pattern between first and last channels""" + bounce_position = (self.pattern_position * 0.8) % (2 * (active_channel_count - 1)) + + if bounce_position <= active_channel_count - 1: + target_channel = bounce_position + 1 + else: + target_channel = 2 * active_channel_count - bounce_position - 1 + + distance = abs(channel - target_channel) + return max(0.3, 1.0 - distance * 0.3) + + def get_all_channel_volumes(self, active_channel_count: int) -> Dict[int, float]: + """Get volume values for all active channels""" + volumes = {} + for channel in range(1, active_channel_count + 1): + volume = self.get_channel_volume(channel, active_channel_count) + volumes[channel] = volume + self.volume_updated.emit(channel, volume) + return volumes + + def reset_pattern(self): + """Reset pattern to beginning""" + self.pattern_position = 0.0 + for channel in range(1, 17): + self.pattern_phases[channel] = random.random() * 2 * math.pi + self.random_states[channel] = random.random() \ No newline at end of file diff --git a/diagnose_audio_midi.py b/diagnose_audio_midi.py new file mode 100644 index 0000000..bf0d6a5 --- /dev/null +++ b/diagnose_audio_midi.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Audio and MIDI Diagnostic Tool +Helps identify issues with audio playback and MIDI output +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_pygame_audio(): + """Test pygame audio system directly""" + print("=== Testing Pygame Audio ===") + + try: + import pygame + import numpy as np + + # Initialize pygame mixer + pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=512) + pygame.mixer.init() + print("PASS: Pygame mixer initialized successfully") + + # Test basic audio playback + print("Testing basic audio generation...") + + # Generate a simple sine wave (440 Hz for 1 second) + sample_rate = 22050 + duration = 1.0 # seconds + frequency = 440 # A4 + + # Generate samples + samples = np.sin(2 * np.pi * frequency * np.linspace(0, duration, int(sample_rate * duration))) + + # Apply envelope and scale + envelope = np.exp(-np.linspace(0, 3, len(samples))) # Fade out + samples = samples * envelope * 0.5 + + # Convert to 16-bit integers and make stereo + samples = (samples * 32767).astype(np.int16) + stereo_samples = np.column_stack((samples, samples)) + + # Create and play sound + sound = pygame.sndarray.make_sound(stereo_samples) + print("Playing test tone (440Hz for 1 second)...") + sound.play() + + # Wait for playback + time.sleep(1.2) + + print("✓ Direct pygame audio test completed") + return True + + except Exception as e: + print(f"✗ Pygame audio test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_simulator_audio(): + """Test the simulator audio system""" + print("\n=== Testing Simulator Audio ===") + + try: + from simulator.simulator_engine import SimulatorEngine + + simulator = SimulatorEngine() + print(f"✓ Simulator initialized, audio enabled: {simulator.audio_enabled}") + print(f" Audio initialized: {simulator.audio_initialized_flag}") + print(f" Stereo mode: {getattr(simulator, 'stereo_mode', 'Unknown')}") + + # Test note playback + print("Testing note playback through simulator...") + simulator.play_note(1, 60, 80) # Channel 1, Middle C, velocity 80 + time.sleep(0.8) + simulator.stop_note(1, 60) + + print("✓ Simulator audio test completed") + return True + + except Exception as e: + print(f"✗ Simulator audio test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_midi_devices(): + """Test MIDI device detection""" + print("\n=== Testing MIDI Devices ===") + + try: + import mido + print("✓ Mido library available") + + # List MIDI outputs + outputs = mido.get_output_names() + print(f"Available MIDI outputs ({len(outputs)}):") + for i, output in enumerate(outputs): + print(f" {i+1}. {output}") + + if outputs: + print(f"\nTesting MIDI output with: {outputs[0]}") + try: + midi_out = mido.open_output(outputs[0]) + print("✓ MIDI output opened successfully") + + # Send test note + print("Sending test note (Middle C)...") + msg_on = mido.Message('note_on', channel=0, note=60, velocity=80) + msg_off = mido.Message('note_off', channel=0, note=60, velocity=0) + + midi_out.send(msg_on) + print(" - Note ON sent") + time.sleep(0.5) + midi_out.send(msg_off) + print(" - Note OFF sent") + + midi_out.close() + print("✓ MIDI test completed") + return True + + except Exception as e: + print(f"✗ MIDI output test failed: {e}") + return False + else: + print("⚠ No MIDI outputs available") + return False + + except ImportError: + print("✗ Mido library not available") + return False + except Exception as e: + print(f"✗ MIDI test failed: {e}") + return False + +def test_arpeggiator_integration(): + """Test the full arpeggiator system""" + print("\n=== Testing Arpeggiator Integration ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create components + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + print("✓ All components initialized") + + # Test simulator mode + output_manager.set_mode("simulator") + print(f"✓ Output mode: {output_manager.current_mode}") + + # Configure arpeggiator + arpeggiator.set_root_note(60) # Middle C + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + + # Add notes to trigger arpeggiator + print("Adding notes and starting arpeggiator...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + # Start arpeggiator + started = arpeggiator.start() + print(f"✓ Arpeggiator started: {started}") + + if started: + print("Letting arpeggiator run for 3 seconds...") + time.sleep(3) + arpeggiator.stop() + print("✓ Arpeggiator stopped") + + return True + + except Exception as e: + print(f"✗ Arpeggiator integration test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_system_audio(): + """Test system audio capabilities""" + print("\n=== Testing System Audio ===") + + try: + import pygame + + # Get mixer info + pygame.mixer.init() + print(f"✓ Pygame version: {pygame.version.ver}") + + # Check mixer settings + freq = pygame.mixer.get_init() + if freq: + print(f"✓ Mixer frequency: {freq[0]}Hz, {freq[1]}-bit, {freq[2]} channels") + else: + print("✗ Mixer not initialized") + + # Check number of channels + channels = pygame.mixer.get_num_channels() + print(f"✓ Available sound channels: {channels}") + + return True + + except Exception as e: + print(f"✗ System audio test failed: {e}") + return False + +def main(): + """Run all diagnostic tests""" + print("MIDI Arpeggiator - Audio & MIDI Diagnostics") + print("=" * 50) + + results = {} + + # Run tests + results['system_audio'] = test_system_audio() + results['pygame_audio'] = test_pygame_audio() + results['simulator_audio'] = test_simulator_audio() + results['midi_devices'] = test_midi_devices() + results['arpeggiator'] = test_arpeggiator_integration() + + # Summary + print("\n" + "=" * 50) + print("DIAGNOSTIC SUMMARY:") + print("=" * 50) + + for test_name, result in results.items(): + status = "✓ PASS" if result else "✗ FAIL" + print(f"{test_name:20}: {status}") + + # Recommendations + print("\nRECOMMENDATIONS:") + print("-" * 30) + + if not results['pygame_audio']: + print("• Audio issues detected - check Windows audio settings") + print("• Try different audio drivers or devices") + print("• Check if other applications are using audio exclusively") + + if not results['midi_devices']: + print("• No MIDI devices found for hardware mode") + print("• Install virtual MIDI cables (like loopMIDI) for software synths") + print("• Check MIDI device drivers") + + if not results['simulator_audio']: + print("• Simulator audio not working - check pygame audio initialization") + + if not results['arpeggiator']: + print("• Arpeggiator integration issues - check component initialization") + + print(f"\nOverall success rate: {sum(results.values())}/{len(results)} tests passed") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fallback/__init__.py b/fallback/__init__.py new file mode 100644 index 0000000..978c29b --- /dev/null +++ b/fallback/__init__.py @@ -0,0 +1 @@ +# Fallback MIDI implementations \ No newline at end of file diff --git a/fallback/rtmidi_fallback.py b/fallback/rtmidi_fallback.py new file mode 100644 index 0000000..c4136aa --- /dev/null +++ b/fallback/rtmidi_fallback.py @@ -0,0 +1,92 @@ +""" +Fallback RTMIDI implementation using pygame.midi for Windows compatibility. +""" + +import pygame.midi +import time +from typing import List, Optional, Callable + +class MidiOut: + def __init__(self, device_id): + pygame.midi.init() + self.device_id = device_id + self.midi_out = pygame.midi.Output(device_id) + + def send_message(self, message): + """Send MIDI message""" + if hasattr(message, 'bytes'): + # mido message + data = message.bytes() + else: + # Raw bytes + data = message + + if len(data) == 3: + self.midi_out.write_short(data[0], data[1], data[2]) + elif len(data) == 2: + self.midi_out.write_short(data[0], data[1]) + + def close(self): + if hasattr(self, 'midi_out'): + self.midi_out.close() + +class MidiIn: + def __init__(self, device_id, callback=None): + pygame.midi.init() + self.device_id = device_id + self.midi_in = pygame.midi.Input(device_id) + self.callback = callback + + def set_callback(self, callback): + self.callback = callback + + def poll(self): + """Poll for MIDI input (call this regularly)""" + if self.midi_in.poll() and self.callback: + midi_events = self.midi_in.read(10) + for event in midi_events: + # Convert pygame midi event to mido-like message + if self.callback: + self.callback(event) + + def close(self): + if hasattr(self, 'midi_in'): + self.midi_in.close() + +def get_output_names() -> List[str]: + """Get available MIDI output device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3]: # is_output + devices.append(info[1].decode()) + return devices + +def get_input_names() -> List[str]: + """Get available MIDI input device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2]: # is_input + devices.append(info[1].decode()) + return devices + +def open_output(name: str) -> MidiOut: + """Open MIDI output by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3] and info[1].decode() == name: # is_output and name matches + return MidiOut(i) + raise ValueError(f"MIDI output '{name}' not found") + +def open_input(name: str, callback=None) -> MidiIn: + """Open MIDI input by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2] and info[1].decode() == name: # is_input and name matches + return MidiIn(i, callback) + raise ValueError(f"MIDI input '{name}' not found") diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..6b04a7a --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1 @@ +# GUI module - PyQt5 user interface components \ No newline at end of file diff --git a/gui/__pycache__/__init__.cpython-310.pyc b/gui/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8260f97c92b26b82bbdd753383a4a0656a5b9590 GIT binary patch literal 127 zcmd1j<>g`kf=8nJGW3A-V-N=!FakLaKwQiMBvKfH88jLFRx%WUgb~CqC18h!u( literal 0 HcmV?d00001 diff --git a/gui/__pycache__/arpeggiator_controls.cpython-310.pyc b/gui/__pycache__/arpeggiator_controls.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5c07be7a883b453f35db160661721ff218c71cfb GIT binary patch literal 15040 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 nQ9ZLw>GIhHcLEdZ{?dhw5$t)noCKPIm7WK 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 OWDIkR52`);wDo@_!!`c^ literal 0 HcmV?d00001 diff --git a/gui/__pycache__/preset_controls.cpython-310.pyc b/gui/__pycache__/preset_controls.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1497be384e225b3ba77506c48b56c1426644c323 GIT binary patch literal 13754 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 Sf)Wx-doo+K;CtwE#{U8bg6VMp literal 0 HcmV?d00001 diff --git a/gui/__pycache__/simulator_display.cpython-310.pyc b/gui/__pycache__/simulator_display.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61859df0c7e38c5501112f521dae8f1514774705 GIT binary patch literal 6083 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 literal 0 HcmV?d00001 diff --git a/gui/__pycache__/volume_controls.cpython-310.pyc b/gui/__pycache__/volume_controls.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..324d20891144ea70de6b310f97ce17e3e27f72b5 GIT binary patch literal 8522 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 NO>~S)#W{P)`aKP&W&HpE literal 0 HcmV?d00001 diff --git a/gui/arpeggiator_controls.py b/gui/arpeggiator_controls.py new file mode 100644 index 0000000..da736f2 --- /dev/null +++ b/gui/arpeggiator_controls.py @@ -0,0 +1,601 @@ +""" +Arpeggiator Controls - READABLE BUTTONS WITH PROPER SIZING +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Readable arpeggiator controls with properly sized buttons""" + + def __init__(self, arpeggiator, channel_manager, simulator=None): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.simulator = simulator + + # State tracking + self.presets = {} + self.current_preset = None + self.root_note_buttons = {} + self.octave_buttons = {} + self.scale_buttons = {} + self.pattern_buttons = {} + self.distribution_buttons = {} + self.speed_buttons = {} + + self.current_root_note = 0 + self.current_octave = 4 + self.current_scale = "major" + self.current_pattern = "up" + self.current_distribution = "up" + self.current_speed = "1/8" + + # Armed state tracking + self.armed_root_note_button = None + self.armed_octave_button = None + self.armed_scale_button = None + self.armed_pattern_button = None + self.armed_distribution_button = None + # Speed changes apply immediately - no armed state needed + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Clean quadrant layout with readable buttons""" + # Main grid + main = QGridLayout(self) + main.setSpacing(8) + main.setContentsMargins(8, 8, 8, 8) + + # Equal quadrants + main.addWidget(self.basic_quadrant(), 0, 0) + main.addWidget(self.distribution_quadrant(), 0, 1) + main.addWidget(self.pattern_quadrant(), 1, 0) + main.addWidget(self.timing_quadrant(), 1, 1) + + main.setRowStretch(0, 1) + main.setRowStretch(1, 1) + main.setColumnStretch(0, 1) + main.setColumnStretch(1, 1) + + def basic_quadrant(self): + """Basic settings with readable buttons""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + # Root notes - 12 buttons in horizontal row, NO spacing between buttons + layout.addWidget(QLabel("Root Note:")) + notes_widget = QWidget() + notes_layout = QHBoxLayout(notes_widget) + notes_layout.setSpacing(0) # NO spacing between buttons + notes_layout.setContentsMargins(0, 0, 0, 0) + + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + for i, note in enumerate(notes): + btn = QPushButton(note) + btn.setFixedSize(40, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, n=i: self.on_root_note_clicked(n)) + + if i == 0: + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.root_note_buttons[i] = btn + notes_layout.addWidget(btn) + + layout.addWidget(notes_widget) + + # Octaves - 6 buttons in horizontal row, NO spacing between buttons + layout.addWidget(QLabel("Octave:")) + octave_widget = QWidget() + octave_layout = QHBoxLayout(octave_widget) + octave_layout.setSpacing(0) # NO spacing between buttons + octave_layout.setContentsMargins(0, 0, 0, 0) + + for octave in range(3, 9): # C3 to C8 + btn = QPushButton(f"C{octave}") + btn.setFixedSize(50, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, o=octave: self.on_octave_clicked(o)) + + if octave == 4: + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.octave_buttons[octave] = btn + octave_layout.addWidget(btn) + + layout.addWidget(octave_widget) + + # Scales - 2 rows of 4, minimal vertical spacing + layout.addWidget(QLabel("Scale:")) + scales_widget = QWidget() + scales_layout = QGridLayout(scales_widget) + scales_layout.setSpacing(0) # NO horizontal spacing + scales_layout.setVerticalSpacing(2) # Minimal vertical spacing + scales_layout.setContentsMargins(0, 0, 0, 0) + + main_scales = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "pentatonic_major", "pentatonic_minor"] + for i, scale in enumerate(main_scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, s=scale: self.on_scale_clicked(s)) + + if scale == "major": + btn.setChecked(True) + btn.setStyleSheet("background: #00aa44; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #00cc55;") + + self.scale_buttons[scale] = btn + scales_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(scales_widget) + + # Octave range dropdown + layout.addWidget(QLabel("Octave Range:")) + self.octave_range_combo = QComboBox() + self.octave_range_combo.setFixedHeight(20) + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + layout.addWidget(self.octave_range_combo) + + return group + + def distribution_quadrant(self): + """Distribution with readable buttons and simulator display""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + layout.addWidget(QLabel("Distribution Pattern:")) + + # 2 rows of 4 distribution buttons + dist_widget = QWidget() + dist_layout = QGridLayout(dist_widget) + dist_layout.setSpacing(0) # NO horizontal spacing + dist_layout.setVerticalSpacing(2) # Minimal vertical spacing + dist_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "bounce", "random", "cycle", "alternating", "single_channel"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, p=pattern: self.on_distribution_clicked(p)) + + if pattern == "up": + btn.setChecked(True) + btn.setStyleSheet("background: #0066cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #0088ee;") + + self.distribution_buttons[pattern] = btn + dist_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(dist_widget) + + # Description + self.dist_desc = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.dist_desc.setStyleSheet("font-size: 10px; color: gray;") + layout.addWidget(self.dist_desc) + + # Simulator display + if self.simulator: + from .simulator_display import SimulatorDisplay + self.simulator_display = SimulatorDisplay(self.simulator, self.channel_manager) + layout.addWidget(self.simulator_display) + else: + # Create placeholder for now + placeholder = QLabel("Simulator display will appear here") + placeholder.setStyleSheet("font-size: 10px; color: gray; text-align: center;") + placeholder.setAlignment(Qt.AlignCenter) + layout.addWidget(placeholder) + + return group + + def pattern_quadrant(self): + """Pattern with readable buttons""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + layout.addWidget(QLabel("Arpeggio Pattern:")) + + # 2 rows of 4 pattern buttons + pattern_widget = QWidget() + pattern_layout = QGridLayout(pattern_widget) + pattern_layout.setSpacing(0) # NO horizontal spacing + pattern_layout.setVerticalSpacing(2) # Minimal vertical spacing + pattern_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "down_up", "random", "chord", "note_order", "custom"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] + + btn = QPushButton(display_name) + btn.setFixedSize(120, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, p=pattern: self.on_pattern_clicked(p)) + + if pattern == "up": + btn.setChecked(True) + btn.setStyleSheet("background: #cc6600; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ee8800;") + + self.pattern_buttons[pattern] = btn + pattern_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(pattern_widget) + + return group + + def timing_quadrant(self): + """Timing with readable controls""" + group = QGroupBox("Timing Settings") + layout = QVBoxLayout(group) + layout.setSpacing(6) + layout.setContentsMargins(8, 8, 8, 8) + + # Tempo + tempo_layout = QHBoxLayout() + tempo_layout.addWidget(QLabel("Tempo:")) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + self.tempo_spin.setFixedHeight(20) + tempo_layout.addWidget(self.tempo_spin) + layout.addLayout(tempo_layout) + + # Speed buttons + layout.addWidget(QLabel("Note Speed:")) + speed_widget = QWidget() + speed_layout = QHBoxLayout(speed_widget) + speed_layout.setSpacing(0) # NO spacing between buttons + speed_layout.setContentsMargins(0, 0, 0, 0) + + self.speed_buttons = {} + speeds = ["1/32", "1/16", "1/8", "1/4", "1/2", "1/1"] + for speed in speeds: + btn = QPushButton(speed) + btn.setFixedSize(50, 22) # Taller buttons for better readability + btn.setCheckable(True) + btn.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + btn.clicked.connect(lambda checked, s=speed: self.on_speed_clicked(s)) + + if speed == "1/8": + btn.setChecked(True) + btn.setStyleSheet("background: #9933cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + + self.speed_buttons[speed] = btn + speed_layout.addWidget(btn) + + layout.addWidget(speed_widget) + + # Gate + gate_layout = QHBoxLayout() + gate_layout.addWidget(QLabel("Gate:")) + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) + self.gate_slider.setValue(100) + self.gate_slider.setFixedHeight(20) + gate_layout.addWidget(self.gate_slider) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(40) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout) + + # Swing + swing_layout = QHBoxLayout() + swing_layout.addWidget(QLabel("Swing:")) + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + self.swing_slider.setFixedHeight(20) + swing_layout.addWidget(self.swing_slider) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(40) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout) + + # Velocity + velocity_layout = QHBoxLayout() + velocity_layout.addWidget(QLabel("Velocity:")) + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + self.velocity_slider.setFixedHeight(20) + velocity_layout.addWidget(self.velocity_slider) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(40) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout) + + # Presets + preset_layout = QHBoxLayout() + self.save_btn = QPushButton("Save Preset") + self.save_btn.setFixedSize(80, 20) + self.load_btn = QPushButton("Load Preset") + self.load_btn.setFixedSize(80, 20) + preset_layout.addWidget(self.save_btn) + preset_layout.addWidget(self.load_btn) + preset_layout.addStretch() + layout.addLayout(preset_layout) + + return group + + def connect_signals(self): + """Connect all signals""" + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + # Speed is now handled by individual button click handlers + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + self.save_btn.clicked.connect(self.save_preset) + self.load_btn.clicked.connect(self.load_preset) + + if hasattr(self.arpeggiator, 'armed_state_changed'): + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + # Event handlers + def on_root_note_clicked(self, note_index): + midi_note = self.current_octave * 12 + note_index + + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange, waits for pattern end + if self.armed_root_note_button: + self.armed_root_note_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_root_note_button = self.root_note_buttons[note_index] + self.root_note_buttons[note_index].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_root_note'): + self.arpeggiator.arm_root_note(midi_note) + else: + # IMMEDIATE CHANGE - apply right away + if self.current_root_note in self.root_note_buttons: + self.root_note_buttons[self.current_root_note].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_root_note = note_index + self.root_note_buttons[note_index].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_root_note'): + self.arpeggiator.set_root_note(midi_note) + + def on_octave_clicked(self, octave): + midi_note = octave * 12 + self.current_root_note + + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_octave_button: + self.armed_octave_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_octave_button = self.octave_buttons[octave] + self.octave_buttons[octave].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_root_note'): + self.arpeggiator.arm_root_note(midi_note) + else: + # IMMEDIATE CHANGE + if self.current_octave in self.octave_buttons: + self.octave_buttons[self.current_octave].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_octave = octave + self.octave_buttons[octave].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_root_note'): + self.arpeggiator.set_root_note(midi_note) + + def on_scale_clicked(self, scale): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_scale_button: + self.armed_scale_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_scale_button = self.scale_buttons[scale] + self.scale_buttons[scale].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_scale'): + self.arpeggiator.arm_scale(scale) + else: + # IMMEDIATE CHANGE + if self.current_scale in self.scale_buttons: + self.scale_buttons[self.current_scale].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_scale = scale + self.scale_buttons[scale].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_scale'): + self.arpeggiator.set_scale(scale) + + def on_pattern_clicked(self, pattern): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_pattern_button: + self.armed_pattern_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_pattern_button = self.pattern_buttons[pattern] + self.pattern_buttons[pattern].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_pattern_type'): + self.arpeggiator.arm_pattern_type(pattern) + else: + # IMMEDIATE CHANGE + if self.current_pattern in self.pattern_buttons: + self.pattern_buttons[self.current_pattern].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_pattern = pattern + self.pattern_buttons[pattern].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_pattern_type'): + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_clicked(self, pattern): + if hasattr(self.arpeggiator, 'is_playing') and self.arpeggiator.is_playing: + # ARMED STATE - button turns orange + if self.armed_distribution_button: + self.armed_distribution_button.setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.armed_distribution_button = self.distribution_buttons[pattern] + self.distribution_buttons[pattern].setStyleSheet("background: #ff8800; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #ffaa00;") + + if hasattr(self.arpeggiator, 'arm_channel_distribution'): + self.arpeggiator.arm_channel_distribution(pattern) + else: + # IMMEDIATE CHANGE + if self.current_distribution in self.distribution_buttons: + self.distribution_buttons[self.current_distribution].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + + self.current_distribution = pattern + self.distribution_buttons[pattern].setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + + if hasattr(self.arpeggiator, 'set_channel_distribution'): + self.arpeggiator.set_channel_distribution(pattern) + + def on_speed_clicked(self, speed): + # Speed changes apply immediately (no armed state needed for timing) + if self.current_speed in self.speed_buttons: + self.speed_buttons[self.current_speed].setStyleSheet("background: #3a3a3a; color: #ffffff; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #555555;") + + self.current_speed = speed + self.speed_buttons[speed].setStyleSheet("background: #9933cc; color: white; font-size: 12px; font-weight: bold; padding: 0px; border: 1px solid #bb55ee;") + + if hasattr(self.arpeggiator, 'set_note_speed'): + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + if hasattr(self.arpeggiator, 'set_tempo'): + self.arpeggiator.set_tempo(float(tempo)) + + # on_speed_changed removed - now using on_speed_clicked with buttons + + @pyqtSlot(int) + def on_gate_changed(self, value): + self.gate_label.setText(f"{value}%") + if hasattr(self.arpeggiator, 'set_gate'): + self.arpeggiator.set_gate(value / 100.0) + + @pyqtSlot(int) + def on_swing_changed(self, value): + self.swing_label.setText(f"{value}%") + if hasattr(self.arpeggiator, 'set_swing'): + self.arpeggiator.set_swing(value / 100.0) + + @pyqtSlot(int) + def on_velocity_changed(self, value): + self.velocity_label.setText(str(value)) + if hasattr(self.arpeggiator, 'set_velocity'): + self.arpeggiator.set_velocity(value) + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + if hasattr(self.arpeggiator, 'set_octave_range'): + self.arpeggiator.set_octave_range(index + 1) + + def save_preset(self): + preset_name = f"Preset_{len(self.presets) + 1}" + self.presets[preset_name] = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution + } + print(f"Saved {preset_name}") + + def load_preset(self): + if not self.presets: + print("No presets saved") + return + preset_name = list(self.presets.keys())[0] + preset = self.presets[preset_name] + # Apply preset logic here + print(f"Loaded {preset_name}") + + @pyqtSlot() + def update_armed_states(self): + """Handle armed state updates - orange buttons become green at pattern end""" + # Check if armed states were applied (armed values become None when applied) + + # Root note armed -> active + if self.armed_root_note_button and hasattr(self.arpeggiator, 'armed_root_note') and self.arpeggiator.armed_root_note is None: + # Find which note this was + for note_index, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + # Clear old active + if self.current_root_note in self.root_note_buttons: + self.root_note_buttons[self.current_root_note].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + # Set new active (orange -> green) + self.current_root_note = note_index + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_root_note_button = None + break + + # Octave armed -> active + if self.armed_octave_button and hasattr(self.arpeggiator, 'armed_root_note') and self.arpeggiator.armed_root_note is None: + for octave, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + if self.current_octave in self.octave_buttons: + self.octave_buttons[self.current_octave].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_octave = octave + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_octave_button = None + break + + # Scale armed -> active + if self.armed_scale_button and hasattr(self.arpeggiator, 'armed_scale') and self.arpeggiator.armed_scale is None: + for scale, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + if self.current_scale in self.scale_buttons: + self.scale_buttons[self.current_scale].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_scale = scale + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_scale_button = None + break + + # Pattern armed -> active + if self.armed_pattern_button and hasattr(self.arpeggiator, 'armed_pattern_type') and self.arpeggiator.armed_pattern_type is None: + for pattern, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + if self.current_pattern in self.pattern_buttons: + self.pattern_buttons[self.current_pattern].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_pattern = pattern + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_pattern_button = None + break + + # Distribution armed -> active + if self.armed_distribution_button and hasattr(self.arpeggiator, 'armed_channel_distribution') and self.arpeggiator.armed_channel_distribution is None: + for distribution, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + if self.current_distribution in self.distribution_buttons: + self.distribution_buttons[self.current_distribution].setStyleSheet("font-size: 12px; font-weight: bold; padding: 0px;") + self.current_distribution = distribution + btn.setStyleSheet("background: #4CAF50; color: white; font-size: 12px; font-weight: bold; padding: 0px;") + self.armed_distribution_button = None + break + + # Speed changes apply immediately - no armed state needed \ No newline at end of file diff --git a/gui/arpeggiator_controls_backup.py b/gui/arpeggiator_controls_backup.py new file mode 100644 index 0000000..474345d --- /dev/null +++ b/gui/arpeggiator_controls_backup.py @@ -0,0 +1,964 @@ +""" +Arpeggiator Controls GUI + +Control panel for arpeggiator settings including patterns, scales, timing, etc. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QCheckBox, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Control panel for arpeggiator parameters""" + + def __init__(self, arpeggiator, channel_manager): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + + # Preset system + self.presets = {} + self.current_preset = None + self.preset_rotation_enabled = False + self.preset_rotation_interval = 4 # patterns + self.preset_rotation_timer = None + self.pattern_count_since_preset_change = 0 + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface with quadrant layout""" + layout = QGridLayout(self) + layout.setSpacing(5) # Reduced from 15 to 5 + layout.setContentsMargins(5, 5, 5, 5) # Minimal margins + + # Make columns equal width and rows equal height + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 1) + layout.setRowStretch(0, 1) + layout.setRowStretch(1, 1) + + # Top-left: Basic Settings + basic_group = self.create_basic_settings() + layout.addWidget(basic_group, 0, 0) + + # Top-right: Channel Distribution + distribution_group = self.create_distribution_settings() + layout.addWidget(distribution_group, 0, 1) + + # Bottom-left: Pattern Settings + pattern_group = self.create_pattern_settings() + layout.addWidget(pattern_group, 1, 0) + + # Bottom-right: Timing Settings + timing_group = self.create_timing_settings() + layout.addWidget(timing_group, 1, 1) + + # Add preset rotation controls + self.setup_preset_rotation() + + def create_basic_settings(self) -> QGroupBox: + """Create basic arpeggiator settings - no scrollbars, all visible""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # 12 Note buttons - all visible in a compact grid + note_frame = QFrame() + note_layout = QVBoxLayout(note_frame) + note_layout.setSpacing(3) + note_layout.addWidget(QLabel("Root Note:")) + note_widget = self.create_note_buttons() + note_layout.addWidget(note_widget) + layout.addWidget(note_frame) + + # 12 Octave select buttons - all visible in a row + octave_frame = QFrame() + octave_layout = QVBoxLayout(octave_frame) + octave_layout.setSpacing(3) + octave_layout.addWidget(QLabel("Octave:")) + octave_widget = self.create_octave_buttons() + octave_layout.addWidget(octave_widget) + layout.addWidget(octave_frame) + + # Scale buttons - compact grid, all visible + scale_frame = QFrame() + scale_layout = QVBoxLayout(scale_frame) + scale_layout.setSpacing(3) + scale_layout.addWidget(QLabel("Scale:")) + scale_widget = self.create_scale_buttons() + scale_layout.addWidget(scale_widget) + layout.addWidget(scale_frame) + + # Octave Range dropdown + octave_frame = QFrame() + octave_layout = QVBoxLayout(octave_frame) + octave_layout.setSpacing(3) + octave_layout.addWidget(QLabel("Octave Range:")) + self.octave_range_combo = QComboBox() + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + self.octave_range_combo.setCurrentIndex(0) # 1 octave + octave_layout.addWidget(self.octave_range_combo) + layout.addWidget(octave_frame) + + return group + + def create_note_buttons(self) -> QWidget: + """Create 12 note selection buttons in compact layout""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + # Note names for one octave + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + self.root_note_buttons = {} + self.current_root_note = 0 # C (index in notes array) + self.armed_root_note_button = None + + # Create 12 note buttons in 2 rows of 6 + for i, note in enumerate(notes): + button = QPushButton(note) + button.setCheckable(True) + button.setFixedSize(40, 20) + button.clicked.connect(lambda checked, n=i: self.on_root_note_button_clicked(n)) + + # Set initial state + if i == 0: # C + button.setChecked(True) + self.update_root_note_button_style(button, "active") + else: + self.update_root_note_button_style(button, "inactive") + + self.root_note_buttons[i] = button + layout.addWidget(button, i // 6, i % 6) + + return widget + + def create_octave_buttons(self) -> QWidget: + """Create 12 octave select buttons (C0 to C11)""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.octave_buttons = {} + self.current_octave = 4 # C4 + self.armed_octave_button = None + self.armed_root_note_button = None + + # Create octave buttons C0 to C11 (12 octaves) + for octave in range(12): + button = QPushButton(f"C{octave}") + button.setCheckable(True) + button.setFixedSize(40, 20) + button.clicked.connect(lambda checked, o=octave: self.on_octave_button_clicked(o)) + + # Set initial state + if octave == 4: # C4 + button.setChecked(True) + self.update_octave_button_style(button, "active") + else: + self.update_octave_button_style(button, "inactive") + + self.octave_buttons[octave] = button + layout.addWidget(button, octave // 6, octave % 6) + + return widget + + def create_scale_buttons(self) -> QWidget: + """Create scale selection buttons - compact layout""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.scale_buttons = {} + self.current_scale = "major" + self.armed_scale_button = None + + scales = list(self.arpeggiator.SCALES.keys()) + + # Create scales in a compact grid + for i, scale in enumerate(scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(70, 20) + button.clicked.connect(lambda checked, s=scale: self.on_scale_button_clicked(s)) + + # Set initial state + if scale == "major": + button.setChecked(True) + self.update_scale_button_style(button, "active") + else: + self.update_scale_button_style(button, "inactive") + + self.scale_buttons[scale] = button + layout.addWidget(button, i // 4, i % 4) # 4 buttons per row + + return widget + + def update_root_note_button_style(self, button, state): + """Update button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def update_octave_button_style(self, button, state): + """Update octave button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def update_scale_button_style(self, button, state): + """Update scale button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_pattern_buttons(self) -> QWidget: + """Create pattern selection buttons - compact grid""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.pattern_buttons = {} + self.current_pattern = "up" + self.armed_pattern_button = None + + patterns = self.arpeggiator.PATTERN_TYPES + + # Create patterns in a compact grid + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(70, 20) + button.clicked.connect(lambda checked, p=pattern: self.on_pattern_button_clicked(p)) + + # Set initial state + if pattern == "up": + button.setChecked(True) + self.update_pattern_button_style(button, "active") + else: + self.update_pattern_button_style(button, "inactive") + + self.pattern_buttons[pattern] = button + layout.addWidget(button, i // 3, i % 3) # 3 buttons per row + + return widget + + def update_pattern_button_style(self, button, state): + """Update pattern button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_distribution_buttons(self) -> QWidget: + """Create distribution selection buttons - compact grid""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(1) + layout.setContentsMargins(0, 0, 0, 0) + + self.distribution_buttons = {} + self.current_distribution = "up" + self.armed_distribution_button = None + + patterns = self.arpeggiator.CHANNEL_DISTRIBUTION_PATTERNS + + # Create distributions in a compact grid + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 12: + display_name = display_name[:12] # Truncate long names + + button = QPushButton(display_name) + button.setCheckable(True) + button.setFixedSize(80, 20) + button.clicked.connect(lambda checked, p=pattern: self.on_distribution_button_clicked(p)) + + # Set initial state + if pattern == "up": + button.setChecked(True) + self.update_distribution_button_style(button, "active") + else: + self.update_distribution_button_style(button, "inactive") + + self.distribution_buttons[pattern] = button + layout.addWidget(button, i // 3, i % 3) # 3 buttons per row + + return widget + + def update_distribution_button_style(self, button, state): + """Update distribution button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 8px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 8px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 8px; + } + """) + + def create_pattern_settings(self) -> QGroupBox: + """Create pattern settings controls - no scrollbars""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # Pattern Type Buttons - all visible + pattern_frame = QFrame() + pattern_layout = QVBoxLayout(pattern_frame) + pattern_layout.addWidget(QLabel("Pattern:")) + pattern_widget = self.create_pattern_buttons() + pattern_layout.addWidget(pattern_widget) + layout.addWidget(pattern_frame) + + return group + + def create_distribution_settings(self) -> QGroupBox: + """Create channel distribution settings - no scrollbars""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(2) + layout.setContentsMargins(5, 5, 5, 5) + + # Channel Distribution Pattern Buttons - all visible + dist_frame = QFrame() + dist_layout = QVBoxLayout(dist_frame) + dist_layout.addWidget(QLabel("Distribution:")) + distribution_widget = self.create_distribution_buttons() + dist_layout.addWidget(distribution_widget) + layout.addWidget(dist_frame) + + # Description label + self.distribution_description = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.distribution_description.setStyleSheet("color: #888888; font-style: italic;") + self.distribution_description.setWordWrap(True) + layout.addWidget(self.distribution_description) + + return group + + def create_timing_settings(self) -> QGroupBox: + """Create timing controls""" + group = QGroupBox("Timing Settings") + layout = QGridLayout(group) + + # Tempo + layout.addWidget(QLabel("Tempo:"), 0, 0) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + layout.addWidget(self.tempo_spin, 0, 1) + + # Note Speed + layout.addWidget(QLabel("Note Speed:"), 1, 0) + self.speed_combo = QComboBox() + speeds = list(self.arpeggiator.NOTE_SPEEDS.keys()) + for speed in speeds: + self.speed_combo.addItem(speed) + self.speed_combo.setCurrentText("1/8") + layout.addWidget(self.speed_combo, 1, 1) + + # Gate (Note Length) + layout.addWidget(QLabel("Gate:"), 2, 0) + gate_layout = QHBoxLayout() + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) # 10% to 200% + self.gate_slider.setValue(100) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(40) + gate_layout.addWidget(self.gate_slider) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout, 2, 1) + + # Swing + layout.addWidget(QLabel("Swing:"), 3, 0) + swing_layout = QHBoxLayout() + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(40) + swing_layout.addWidget(self.swing_slider) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout, 3, 1) + + # Base Velocity + layout.addWidget(QLabel("Velocity:"), 4, 0) + velocity_layout = QHBoxLayout() + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(40) + velocity_layout.addWidget(self.velocity_slider) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout, 4, 1) + + return group + + def setup_preset_rotation(self): + """Setup preset rotation controls""" + # Find the timing settings group that was just created + timing_groups = self.findChildren(QGroupBox) + timing_group = None + for group in timing_groups: + if group.title() == "Timing Settings": + timing_group = group + break + + if timing_group and hasattr(timing_group, 'layout') and timing_group.layout(): + layout = timing_group.layout() + + # Preset rotation controls + layout.addWidget(QLabel("Preset Rotation:"), 6, 0) + + preset_layout = QVBoxLayout() + + # Enable checkbox + self.preset_rotation_checkbox = QPushButton("Enable Presets") + self.preset_rotation_checkbox.setCheckable(True) + self.preset_rotation_checkbox.setFixedSize(100, 25) + preset_layout.addWidget(self.preset_rotation_checkbox) + + # Interval control + interval_layout = QHBoxLayout() + interval_layout.addWidget(QLabel("Every:")) + self.preset_interval_spin = QSpinBox() + self.preset_interval_spin.setRange(1, 16) + self.preset_interval_spin.setValue(4) + self.preset_interval_spin.setSuffix(" loops") + self.preset_interval_spin.setFixedSize(80, 25) + interval_layout.addWidget(self.preset_interval_spin) + preset_layout.addLayout(interval_layout) + + # Preset buttons + preset_button_layout = QHBoxLayout() + self.save_preset_button = QPushButton("Save") + self.save_preset_button.setFixedSize(50, 25) + preset_button_layout.addWidget(self.save_preset_button) + + self.load_preset_button = QPushButton("Load") + self.load_preset_button.setFixedSize(50, 25) + preset_button_layout.addWidget(self.load_preset_button) + preset_layout.addLayout(preset_button_layout) + + layout.addLayout(preset_layout, 6, 1) + + def connect_signals(self): + """Connect GUI controls to arpeggiator""" + # Basic settings + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + + # Connect new button signals + for note_index, button in self.root_note_buttons.items(): + button.clicked.connect(lambda checked, n=note_index: self.on_root_note_button_clicked(n)) + + for octave, button in self.octave_buttons.items(): + button.clicked.connect(lambda checked, o=octave: self.on_octave_button_clicked(o)) + + # Timing settings + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + self.speed_combo.currentTextChanged.connect(self.on_speed_changed) + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + + # Arpeggiator state changes + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + + def on_root_note_button_clicked(self, note_index): + """Handle root note button click - uses note index (0-11) and octave""" + midi_note = self.current_octave * 12 + note_index + + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_root_note_button: + self.update_root_note_button_style(self.armed_root_note_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.root_note_buttons[note_index] + self.armed_root_note_button = button + self.update_root_note_button_style(button, "armed") + self.arpeggiator.arm_root_note(midi_note) + else: + # Apply immediately + self.set_active_root_note(note_index) + self.arpeggiator.set_root_note(midi_note) + + def on_scale_button_clicked(self, scale): + """Handle scale button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_scale_button: + self.update_scale_button_style(self.armed_scale_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.scale_buttons[scale] + self.armed_scale_button = button + self.update_scale_button_style(button, "armed") + self.arpeggiator.arm_scale(scale) + else: + # Apply immediately + self.set_active_scale(scale) + self.arpeggiator.set_scale(scale) + + def on_pattern_button_clicked(self, pattern): + """Handle pattern button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_pattern_button: + self.update_pattern_button_style(self.armed_pattern_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.pattern_buttons[pattern] + self.armed_pattern_button = button + self.update_pattern_button_style(button, "armed") + self.arpeggiator.arm_pattern_type(pattern) + else: + # Apply immediately + self.set_active_pattern(pattern) + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_button_clicked(self, distribution): + """Handle distribution button click - FIXED armed state logic""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_distribution_button: + self.update_distribution_button_style(self.armed_distribution_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.distribution_buttons[distribution] + self.armed_distribution_button = button + self.update_distribution_button_style(button, "armed") + self.arpeggiator.arm_channel_distribution(distribution) + else: + # Apply immediately + self.set_active_distribution(distribution) + self.arpeggiator.set_channel_distribution(distribution) + + def on_octave_button_clicked(self, octave): + """Handle octave button click""" + # If arpeggiator is playing, arm the change + if self.arpeggiator.is_playing: + # Clear previous armed state + if self.armed_octave_button: + self.update_octave_button_style(self.armed_octave_button, "inactive") + + # Set new armed state (but keep current active button green) + button = self.octave_buttons[octave] + self.armed_octave_button = button + self.update_octave_button_style(button, "armed") + # Arm the new MIDI note + midi_note = octave * 12 + self.current_root_note + self.arpeggiator.arm_root_note(midi_note) + else: + # Apply immediately + self.set_active_octave(octave) + midi_note = octave * 12 + self.current_root_note + self.arpeggiator.set_root_note(midi_note) + + def set_active_root_note(self, note_index): + """Set active root note button""" + # Clear current active state + if self.current_root_note in self.root_note_buttons: + self.update_root_note_button_style(self.root_note_buttons[self.current_root_note], "inactive") + + # Clear armed state if it's the same note + if self.armed_root_note_button and self.armed_root_note_button == self.root_note_buttons.get(note_index): + self.armed_root_note_button = None + + # Set new active state + self.current_root_note = note_index + if note_index in self.root_note_buttons: + self.update_root_note_button_style(self.root_note_buttons[note_index], "active") + + def set_active_octave(self, octave): + """Set active octave button""" + # Clear current active state + if self.current_octave in self.octave_buttons: + self.update_octave_button_style(self.octave_buttons[self.current_octave], "inactive") + + # Clear armed state if it's the same octave + if self.armed_octave_button and self.armed_octave_button == self.octave_buttons.get(octave): + self.armed_octave_button = None + + # Set new active state + self.current_octave = octave + if octave in self.octave_buttons: + self.update_octave_button_style(self.octave_buttons[octave], "active") + + def set_active_scale(self, scale): + """Set active scale button - FIXED to clear previous active""" + # Clear current active state + if self.current_scale in self.scale_buttons: + self.update_scale_button_style(self.scale_buttons[self.current_scale], "inactive") + + # Clear armed state if it's the same scale + if self.armed_scale_button and self.armed_scale_button == self.scale_buttons.get(scale): + self.armed_scale_button = None + + # Set new active state + self.current_scale = scale + if scale in self.scale_buttons: + self.update_scale_button_style(self.scale_buttons[scale], "active") + + def set_active_pattern(self, pattern): + """Set active pattern button - FIXED to clear previous active""" + # Clear current active state + if self.current_pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + # Clear armed state if it's the same pattern + if self.armed_pattern_button and self.armed_pattern_button == self.pattern_buttons.get(pattern): + self.armed_pattern_button = None + + # Set new active state + self.current_pattern = pattern + if pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[pattern], "active") + + def set_active_distribution(self, distribution): + """Set active distribution button - FIXED to clear previous active""" + # Clear current active state + if self.current_distribution in self.distribution_buttons: + self.update_distribution_button_style(self.distribution_buttons[self.current_distribution], "inactive") + + # Clear armed state if it's the same distribution + if self.armed_distribution_button and self.armed_distribution_button == self.distribution_buttons.get(distribution): + self.armed_distribution_button = None + + # Set new active state + self.current_distribution = distribution + if distribution in self.distribution_buttons: + self.update_distribution_button_style(self.distribution_buttons[distribution], "active") + self.update_distribution_description(distribution) + + @pyqtSlot() + def update_armed_states(self): + """Update armed states when arpeggiator state changes - FIXED logic""" + # This is called when armed states are applied at pattern end + + if self.armed_root_note_button and self.arpeggiator.armed_root_note is None: + # Armed root note was applied, move to active + note_index = None + for n, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + note_index = n + break + if note_index is not None: + self.set_active_root_note(note_index) + + if self.armed_octave_button and self.arpeggiator.armed_root_note is None: + # Armed octave was applied, move to active + octave = None + for o, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + octave = o + break + if octave is not None: + self.set_active_octave(octave) + + if self.armed_scale_button and self.arpeggiator.armed_scale is None: + # Armed scale was applied, move to active + scale = None + for s, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + scale = s + break + if scale: + self.set_active_scale(scale) + + if self.armed_pattern_button and self.arpeggiator.armed_pattern_type is None: + # Armed pattern was applied, move to active + pattern = None + for p, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + pattern = p + break + if pattern: + self.set_active_pattern(pattern) + + if self.armed_distribution_button and self.arpeggiator.armed_channel_distribution is None: + # Armed distribution was applied, move to active + distribution = None + for d, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + distribution = d + break + if distribution: + self.set_active_distribution(distribution) + + def update_distribution_description(self, distribution: str): + """Update distribution pattern description""" + descriptions = { + "up": "Channels: 1 → 2 → 3 → 4 → 5 → 6...", + "down": "Channels: 6 → 5 → 4 → 3 → 2 → 1...", + "up_down": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "bounce": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "random": "Channels: Random selection each note", + "cycle": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 1 → 2...", + "alternating": "Channels: 1 → 6 → 2 → 5 → 3 → 4...", + "single_channel": "Channels: All notes on channel 1" + } + + description = descriptions.get(distribution, "Unknown pattern") + self.distribution_description.setText(description) + + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + """Handle tempo change""" + self.arpeggiator.set_tempo(float(tempo)) + + @pyqtSlot(str) + def on_speed_changed(self, speed): + """Handle note speed change""" + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_gate_changed(self, gate_percent): + """Handle gate change""" + gate_value = gate_percent / 100.0 + self.arpeggiator.set_gate(gate_value) + self.gate_label.setText(f"{gate_percent}%") + + @pyqtSlot(int) + def on_swing_changed(self, swing_percent): + """Handle swing change""" + swing_value = swing_percent / 100.0 + self.arpeggiator.set_swing(swing_value) + self.swing_label.setText(f"{swing_percent}%") + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + """Handle octave range change""" + octaves = index + 1 # Convert 0-based index to 1-4 range + self.arpeggiator.set_octave_range(octaves) + + @pyqtSlot(int) + def on_velocity_changed(self, velocity): + """Handle velocity change""" + self.arpeggiator.set_velocity(velocity) + self.velocity_label.setText(str(velocity)) + + @pyqtSlot(bool) + def on_preset_rotation_toggled(self, enabled): + """Handle preset rotation enable/disable""" + self.preset_rotation_enabled = enabled + print(f"Preset rotation {'enabled' if enabled else 'disabled'}") + + @pyqtSlot(int) + def on_preset_interval_changed(self, interval): + """Handle preset rotation interval change""" + self.preset_rotation_interval = interval + print(f"Preset interval set to {interval} patterns") + + def save_current_preset(self): + """Save current settings as a preset""" + preset_name = f"Preset_{len(self.presets) + 1}" + + preset = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution, + 'octave_range': self.octave_range_combo.currentIndex() if hasattr(self, 'octave_range_combo') else 0, + 'tempo': self.tempo_spin.value(), + 'speed': self.speed_combo.currentText(), + 'gate': self.gate_slider.value(), + 'swing': self.swing_slider.value(), + 'velocity': self.velocity_slider.value() + } + + self.presets[preset_name] = preset + print(f"Saved {preset_name}") + + def load_preset_dialog(self): + """Show preset selection dialog""" + if not self.presets: + print("No presets saved") + return + + # For now, cycle through presets + preset_names = list(self.presets.keys()) + if self.current_preset in preset_names: + current_index = preset_names.index(self.current_preset) + next_index = (current_index + 1) % len(preset_names) + else: + next_index = 0 + + next_preset = preset_names[next_index] + self.load_preset(next_preset) + + def load_preset(self, preset_name: str): + """Load a specific preset""" + if preset_name not in self.presets: + return + + preset = self.presets[preset_name] + self.current_preset = preset_name + + # Apply preset settings + if self.arpeggiator.is_playing: + # Arm changes for pattern-end application + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.arm_root_note(midi_note) + self.arpeggiator.arm_scale(preset['scale']) + self.arpeggiator.arm_pattern_type(preset['pattern']) + self.arpeggiator.arm_channel_distribution(preset['distribution']) + else: + # Apply immediately + self.set_active_root_note(preset['root_note']) + self.set_active_octave(preset['octave']) + self.set_active_scale(preset['scale']) + self.set_active_pattern(preset['pattern']) + self.set_active_distribution(preset['distribution']) + + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.set_root_note(midi_note) + self.arpeggiator.set_scale(preset['scale']) + self.arpeggiator.set_pattern_type(preset['pattern']) + self.arpeggiator.set_channel_distribution(preset['distribution']) + + # Apply other settings immediately + if hasattr(self, 'octave_range_combo'): + self.octave_range_combo.setCurrentIndex(preset['octave_range']) + self.tempo_spin.setValue(preset['tempo']) + self.speed_combo.setCurrentText(preset['speed']) + self.gate_slider.setValue(preset['gate']) + self.swing_slider.setValue(preset['swing']) + self.velocity_slider.setValue(preset['velocity']) + + print(f"Loaded {preset_name}") \ No newline at end of file diff --git a/gui/arpeggiator_controls_new.py b/gui/arpeggiator_controls_new.py new file mode 100644 index 0000000..c8a16b1 --- /dev/null +++ b/gui/arpeggiator_controls_new.py @@ -0,0 +1,691 @@ +""" +Arpeggiator Controls GUI - Complete Redesign + +Clean quadrant layout with no overlapping buttons, guaranteed spacing. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QCheckBox, QFrame, QScrollArea, QSizePolicy) +from PyQt5.QtCore import Qt, pyqtSlot + +class ArpeggiatorControls(QWidget): + """Control panel for arpeggiator parameters - redesigned for no overlaps""" + + def __init__(self, arpeggiator, channel_manager): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + + # Preset system + self.presets = {} + self.current_preset = None + self.preset_rotation_enabled = False + self.preset_rotation_interval = 4 + self.preset_rotation_timer = None + self.pattern_count_since_preset_change = 0 + + # Button tracking + self.root_note_buttons = {} + self.octave_buttons = {} + self.scale_buttons = {} + self.pattern_buttons = {} + self.distribution_buttons = {} + + # Current states + self.current_root_note = 0 # C + self.current_octave = 4 # C4 + self.current_scale = "major" + self.current_pattern = "up" + self.current_distribution = "up" + + # Armed states + self.armed_root_note_button = None + self.armed_octave_button = None + self.armed_scale_button = None + self.armed_pattern_button = None + self.armed_distribution_button = None + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up clean quadrant layout""" + # Main layout - fixed size quadrants + main_layout = QGridLayout(self) + main_layout.setSpacing(10) + main_layout.setContentsMargins(10, 10, 10, 10) + + # Create four equal quadrants + basic_quad = self.create_basic_quadrant() + distribution_quad = self.create_distribution_quadrant() + pattern_quad = self.create_pattern_quadrant() + timing_quad = self.create_timing_quadrant() + + # Add to grid with equal sizing + main_layout.addWidget(basic_quad, 0, 0) + main_layout.addWidget(distribution_quad, 0, 1) + main_layout.addWidget(pattern_quad, 1, 0) + main_layout.addWidget(timing_quad, 1, 1) + + # Make all quadrants equal + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + + def create_basic_quadrant(self): + """Create basic settings quadrant - guaranteed no overlaps""" + group = QGroupBox("Basic Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Root Note - 12 buttons in 3 rows of 4 + root_label = QLabel("Root Note:") + layout.addWidget(root_label) + + root_container = QWidget() + root_layout = QGridLayout(root_container) + root_layout.setSpacing(4) + root_layout.setContentsMargins(0, 0, 0, 0) + + notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] + for i, note in enumerate(notes): + btn = QPushButton(note) + btn.setFixedSize(35, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, n=i: self.on_root_note_clicked(n)) + + if i == 0: # C is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.root_note_buttons[i] = btn + root_layout.addWidget(btn, i // 4, i % 4) + + layout.addWidget(root_container) + + # Octave - 6 buttons in 2 rows of 3 + octave_label = QLabel("Octave:") + layout.addWidget(octave_label) + + octave_container = QWidget() + octave_layout = QGridLayout(octave_container) + octave_layout.setSpacing(4) + octave_layout.setContentsMargins(0, 0, 0, 0) + + for i in range(6): # C3 to C8 + octave = i + 3 + btn = QPushButton(f"C{octave}") + btn.setFixedSize(35, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, o=octave: self.on_octave_clicked(o)) + + if octave == 4: # C4 is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.octave_buttons[octave] = btn + octave_layout.addWidget(btn, i // 3, i % 3) + + layout.addWidget(octave_container) + + # Scales - 8 main scales in 4 rows of 2 + scale_label = QLabel("Scale:") + layout.addWidget(scale_label) + + scale_container = QWidget() + scale_layout = QGridLayout(scale_container) + scale_layout.setSpacing(4) + scale_layout.setContentsMargins(0, 0, 0, 0) + + main_scales = ["major", "minor", "dorian", "phrygian", "lydian", "mixolydian", "pentatonic_major", "pentatonic_minor"] + for i, scale in enumerate(main_scales): + display_name = scale.replace("_", " ").title() + if len(display_name) > 8: + display_name = display_name[:8] + + btn = QPushButton(display_name) + btn.setFixedSize(75, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, s=scale: self.on_scale_clicked(s)) + + if scale == "major": # Major is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.scale_buttons[scale] = btn + scale_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(scale_container) + + # Octave Range dropdown + range_label = QLabel("Octave Range:") + layout.addWidget(range_label) + + self.octave_range_combo = QComboBox() + for i in range(1, 5): + self.octave_range_combo.addItem(f"{i} octave{'s' if i > 1 else ''}") + self.octave_range_combo.setCurrentIndex(0) + layout.addWidget(self.octave_range_combo) + + return group + + def create_distribution_quadrant(self): + """Create channel distribution quadrant""" + group = QGroupBox("Channel Distribution") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Distribution patterns - 8 patterns in 4 rows of 2 + dist_label = QLabel("Distribution Pattern:") + layout.addWidget(dist_label) + + dist_container = QWidget() + dist_layout = QGridLayout(dist_container) + dist_layout.setSpacing(4) + dist_layout.setContentsMargins(0, 0, 0, 0) + + patterns = ["up", "down", "up_down", "bounce", "random", "cycle", "alternating", "single_channel"] + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(85, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, p=pattern: self.on_distribution_clicked(p)) + + if pattern == "up": # Up is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.distribution_buttons[pattern] = btn + dist_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(dist_container) + + # Description + self.distribution_description = QLabel("Channels: 1 → 2 → 3 → 4 → 5 → 6...") + self.distribution_description.setStyleSheet("color: #666; font-style: italic; font-size: 10px;") + self.distribution_description.setWordWrap(True) + layout.addWidget(self.distribution_description) + + return group + + def create_pattern_quadrant(self): + """Create pattern settings quadrant""" + group = QGroupBox("Pattern Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Arpeggio patterns - 8 patterns in 4 rows of 2 + pattern_label = QLabel("Arpeggio Pattern:") + layout.addWidget(pattern_label) + + pattern_container = QWidget() + pattern_layout = QGridLayout(pattern_container) + pattern_layout.setSpacing(4) + pattern_layout.setContentsMargins(0, 0, 0, 0) + + patterns = self.arpeggiator.PATTERN_TYPES[:8] # First 8 patterns + for i, pattern in enumerate(patterns): + display_name = pattern.replace("_", " ").title() + if len(display_name) > 10: + display_name = display_name[:10] + + btn = QPushButton(display_name) + btn.setFixedSize(85, 25) + btn.setCheckable(True) + btn.clicked.connect(lambda checked, p=pattern: self.on_pattern_clicked(p)) + + if pattern == "up": # Up is default + btn.setChecked(True) + self.set_button_style(btn, "active") + else: + self.set_button_style(btn, "inactive") + + self.pattern_buttons[pattern] = btn + pattern_layout.addWidget(btn, i // 2, i % 2) + + layout.addWidget(pattern_container) + + return group + + def create_timing_quadrant(self): + """Create timing settings quadrant""" + group = QGroupBox("Timing Settings") + layout = QVBoxLayout(group) + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 8) + + # Tempo + tempo_layout = QHBoxLayout() + tempo_layout.addWidget(QLabel("Tempo:")) + self.tempo_spin = QSpinBox() + self.tempo_spin.setRange(40, 200) + self.tempo_spin.setValue(120) + self.tempo_spin.setSuffix(" BPM") + tempo_layout.addWidget(self.tempo_spin) + layout.addLayout(tempo_layout) + + # Note Speed + speed_layout = QHBoxLayout() + speed_layout.addWidget(QLabel("Speed:")) + self.speed_combo = QComboBox() + speeds = list(self.arpeggiator.NOTE_SPEEDS.keys()) + for speed in speeds: + self.speed_combo.addItem(speed) + self.speed_combo.setCurrentText("1/8") + speed_layout.addWidget(self.speed_combo) + layout.addLayout(speed_layout) + + # Gate + gate_layout = QHBoxLayout() + gate_layout.addWidget(QLabel("Gate:")) + self.gate_slider = QSlider(Qt.Horizontal) + self.gate_slider.setRange(10, 200) + self.gate_slider.setValue(100) + gate_layout.addWidget(self.gate_slider) + self.gate_label = QLabel("100%") + self.gate_label.setFixedWidth(35) + gate_layout.addWidget(self.gate_label) + layout.addLayout(gate_layout) + + # Swing + swing_layout = QHBoxLayout() + swing_layout.addWidget(QLabel("Swing:")) + self.swing_slider = QSlider(Qt.Horizontal) + self.swing_slider.setRange(-100, 100) + self.swing_slider.setValue(0) + swing_layout.addWidget(self.swing_slider) + self.swing_label = QLabel("0%") + self.swing_label.setFixedWidth(35) + swing_layout.addWidget(self.swing_label) + layout.addLayout(swing_layout) + + # Velocity + velocity_layout = QHBoxLayout() + velocity_layout.addWidget(QLabel("Velocity:")) + self.velocity_slider = QSlider(Qt.Horizontal) + self.velocity_slider.setRange(1, 127) + self.velocity_slider.setValue(80) + velocity_layout.addWidget(self.velocity_slider) + self.velocity_label = QLabel("80") + self.velocity_label.setFixedWidth(35) + velocity_layout.addWidget(self.velocity_label) + layout.addLayout(velocity_layout) + + # Preset controls + preset_layout = QHBoxLayout() + self.save_preset_btn = QPushButton("Save") + self.save_preset_btn.setFixedSize(50, 25) + self.load_preset_btn = QPushButton("Load") + self.load_preset_btn.setFixedSize(50, 25) + preset_layout.addWidget(self.save_preset_btn) + preset_layout.addWidget(self.load_preset_btn) + preset_layout.addStretch() + layout.addLayout(preset_layout) + + return group + + def set_button_style(self, button, state): + """Set button style based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + font-size: 10px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + font-size: 10px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + font-size: 10px; + } + """) + + def connect_signals(self): + """Connect all signals""" + # Timing controls + self.tempo_spin.valueChanged.connect(self.on_tempo_changed) + self.speed_combo.currentTextChanged.connect(self.on_speed_changed) + self.gate_slider.valueChanged.connect(self.on_gate_changed) + self.swing_slider.valueChanged.connect(self.on_swing_changed) + self.velocity_slider.valueChanged.connect(self.on_velocity_changed) + self.octave_range_combo.currentIndexChanged.connect(self.on_octave_range_changed) + + # Preset controls + self.save_preset_btn.clicked.connect(self.save_current_preset) + self.load_preset_btn.clicked.connect(self.load_preset_dialog) + + # Arpeggiator signals + self.arpeggiator.armed_state_changed.connect(self.update_armed_states) + + # Event handlers + def on_root_note_clicked(self, note_index): + """Handle root note button click""" + midi_note = self.current_octave * 12 + note_index + + if self.arpeggiator.is_playing: + self.arm_root_note(note_index) + self.arpeggiator.arm_root_note(midi_note) + else: + self.set_active_root_note(note_index) + self.arpeggiator.set_root_note(midi_note) + + def on_octave_clicked(self, octave): + """Handle octave button click""" + midi_note = octave * 12 + self.current_root_note + + if self.arpeggiator.is_playing: + self.arm_octave(octave) + self.arpeggiator.arm_root_note(midi_note) + else: + self.set_active_octave(octave) + self.arpeggiator.set_root_note(midi_note) + + def on_scale_clicked(self, scale): + """Handle scale button click""" + if self.arpeggiator.is_playing: + self.arm_scale(scale) + self.arpeggiator.arm_scale(scale) + else: + self.set_active_scale(scale) + self.arpeggiator.set_scale(scale) + + def on_pattern_clicked(self, pattern): + """Handle pattern button click""" + if self.arpeggiator.is_playing: + self.arm_pattern(pattern) + self.arpeggiator.arm_pattern_type(pattern) + else: + self.set_active_pattern(pattern) + self.arpeggiator.set_pattern_type(pattern) + + def on_distribution_clicked(self, distribution): + """Handle distribution button click""" + if self.arpeggiator.is_playing: + self.arm_distribution(distribution) + self.arpeggiator.arm_channel_distribution(distribution) + else: + self.set_active_distribution(distribution) + self.arpeggiator.set_channel_distribution(distribution) + + # State management + def set_active_root_note(self, note_index): + """Set active root note""" + if self.current_root_note in self.root_note_buttons: + self.set_button_style(self.root_note_buttons[self.current_root_note], "inactive") + + self.current_root_note = note_index + if note_index in self.root_note_buttons: + self.set_button_style(self.root_note_buttons[note_index], "active") + + def set_active_octave(self, octave): + """Set active octave""" + if self.current_octave in self.octave_buttons: + self.set_button_style(self.octave_buttons[self.current_octave], "inactive") + + self.current_octave = octave + if octave in self.octave_buttons: + self.set_button_style(self.octave_buttons[octave], "active") + + def set_active_scale(self, scale): + """Set active scale""" + if self.current_scale in self.scale_buttons: + self.set_button_style(self.scale_buttons[self.current_scale], "inactive") + + self.current_scale = scale + if scale in self.scale_buttons: + self.set_button_style(self.scale_buttons[scale], "active") + + def set_active_pattern(self, pattern): + """Set active pattern""" + if self.current_pattern in self.pattern_buttons: + self.set_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + self.current_pattern = pattern + if pattern in self.pattern_buttons: + self.set_button_style(self.pattern_buttons[pattern], "active") + + def set_active_distribution(self, distribution): + """Set active distribution""" + if self.current_distribution in self.distribution_buttons: + self.set_button_style(self.distribution_buttons[self.current_distribution], "inactive") + + self.current_distribution = distribution + if distribution in self.distribution_buttons: + self.set_button_style(self.distribution_buttons[distribution], "active") + self.update_distribution_description(distribution) + + def arm_root_note(self, note_index): + """Arm root note for pattern-end change""" + if self.armed_root_note_button: + self.set_button_style(self.armed_root_note_button, "inactive") + + self.armed_root_note_button = self.root_note_buttons[note_index] + self.set_button_style(self.armed_root_note_button, "armed") + + def arm_octave(self, octave): + """Arm octave for pattern-end change""" + if self.armed_octave_button: + self.set_button_style(self.armed_octave_button, "inactive") + + self.armed_octave_button = self.octave_buttons[octave] + self.set_button_style(self.armed_octave_button, "armed") + + def arm_scale(self, scale): + """Arm scale for pattern-end change""" + if self.armed_scale_button: + self.set_button_style(self.armed_scale_button, "inactive") + + self.armed_scale_button = self.scale_buttons[scale] + self.set_button_style(self.armed_scale_button, "armed") + + def arm_pattern(self, pattern): + """Arm pattern for pattern-end change""" + if self.armed_pattern_button: + self.set_button_style(self.armed_pattern_button, "inactive") + + self.armed_pattern_button = self.pattern_buttons[pattern] + self.set_button_style(self.armed_pattern_button, "armed") + + def arm_distribution(self, distribution): + """Arm distribution for pattern-end change""" + if self.armed_distribution_button: + self.set_button_style(self.armed_distribution_button, "inactive") + + self.armed_distribution_button = self.distribution_buttons[distribution] + self.set_button_style(self.armed_distribution_button, "armed") + + @pyqtSlot() + def update_armed_states(self): + """Update when armed states are applied""" + if self.armed_root_note_button and self.arpeggiator.armed_root_note is None: + note_index = None + for n, btn in self.root_note_buttons.items(): + if btn == self.armed_root_note_button: + note_index = n + break + if note_index is not None: + self.set_active_root_note(note_index) + + if self.armed_octave_button and self.arpeggiator.armed_root_note is None: + octave = None + for o, btn in self.octave_buttons.items(): + if btn == self.armed_octave_button: + octave = o + break + if octave is not None: + self.set_active_octave(octave) + + if self.armed_scale_button and self.arpeggiator.armed_scale is None: + scale = None + for s, btn in self.scale_buttons.items(): + if btn == self.armed_scale_button: + scale = s + break + if scale: + self.set_active_scale(scale) + + if self.armed_pattern_button and self.arpeggiator.armed_pattern_type is None: + pattern = None + for p, btn in self.pattern_buttons.items(): + if btn == self.armed_pattern_button: + pattern = p + break + if pattern: + self.set_active_pattern(pattern) + + if self.armed_distribution_button and self.arpeggiator.armed_channel_distribution is None: + distribution = None + for d, btn in self.distribution_buttons.items(): + if btn == self.armed_distribution_button: + distribution = d + break + if distribution: + self.set_active_distribution(distribution) + + def update_distribution_description(self, distribution: str): + """Update distribution description""" + descriptions = { + "up": "Channels: 1 → 2 → 3 → 4 → 5 → 6...", + "down": "Channels: 6 → 5 → 4 → 3 → 2 → 1...", + "up_down": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "bounce": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 5 → 4 → 3 → 2 → 1...", + "random": "Channels: Random selection each note", + "cycle": "Channels: 1 → 2 → 3 → 4 → 5 → 6 → 1 → 2...", + "alternating": "Channels: 1 → 6 → 2 → 5 → 3 → 4...", + "single_channel": "Channels: All notes on channel 1" + } + self.distribution_description.setText(descriptions.get(distribution, "Unknown pattern")) + + # Timing control handlers + @pyqtSlot(int) + def on_tempo_changed(self, tempo): + self.arpeggiator.set_tempo(float(tempo)) + + @pyqtSlot(str) + def on_speed_changed(self, speed): + self.arpeggiator.set_note_speed(speed) + + @pyqtSlot(int) + def on_gate_changed(self, gate_percent): + self.arpeggiator.set_gate(gate_percent / 100.0) + self.gate_label.setText(f"{gate_percent}%") + + @pyqtSlot(int) + def on_swing_changed(self, swing_percent): + self.arpeggiator.set_swing(swing_percent / 100.0) + self.swing_label.setText(f"{swing_percent}%") + + @pyqtSlot(int) + def on_velocity_changed(self, velocity): + self.arpeggiator.set_velocity(velocity) + self.velocity_label.setText(str(velocity)) + + @pyqtSlot(int) + def on_octave_range_changed(self, index): + octaves = index + 1 + self.arpeggiator.set_octave_range(octaves) + + # Preset system + def save_current_preset(self): + """Save current settings as preset""" + preset_name = f"Preset_{len(self.presets) + 1}" + preset = { + 'root_note': self.current_root_note, + 'octave': self.current_octave, + 'scale': self.current_scale, + 'pattern': self.current_pattern, + 'distribution': self.current_distribution, + 'octave_range': self.octave_range_combo.currentIndex(), + 'tempo': self.tempo_spin.value(), + 'speed': self.speed_combo.currentText(), + 'gate': self.gate_slider.value(), + 'swing': self.swing_slider.value(), + 'velocity': self.velocity_slider.value() + } + self.presets[preset_name] = preset + print(f"Saved {preset_name}") + + def load_preset_dialog(self): + """Load next preset""" + if not self.presets: + print("No presets saved") + return + + preset_names = list(self.presets.keys()) + if self.current_preset in preset_names: + current_index = preset_names.index(self.current_preset) + next_index = (current_index + 1) % len(preset_names) + else: + next_index = 0 + + self.load_preset(preset_names[next_index]) + + def load_preset(self, preset_name: str): + """Load specific preset""" + if preset_name not in self.presets: + return + + preset = self.presets[preset_name] + self.current_preset = preset_name + + # Apply settings + if self.arpeggiator.is_playing: + # Arm changes + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.arm_root_note(midi_note) + self.arpeggiator.arm_scale(preset['scale']) + self.arpeggiator.arm_pattern_type(preset['pattern']) + self.arpeggiator.arm_channel_distribution(preset['distribution']) + else: + # Apply immediately + self.set_active_root_note(preset['root_note']) + self.set_active_octave(preset['octave']) + self.set_active_scale(preset['scale']) + self.set_active_pattern(preset['pattern']) + self.set_active_distribution(preset['distribution']) + + midi_note = preset['octave'] * 12 + preset['root_note'] + self.arpeggiator.set_root_note(midi_note) + self.arpeggiator.set_scale(preset['scale']) + self.arpeggiator.set_pattern_type(preset['pattern']) + self.arpeggiator.set_channel_distribution(preset['distribution']) + + # Apply other settings + self.octave_range_combo.setCurrentIndex(preset['octave_range']) + self.tempo_spin.setValue(preset['tempo']) + self.speed_combo.setCurrentText(preset['speed']) + self.gate_slider.setValue(preset['gate']) + self.swing_slider.setValue(preset['swing']) + self.velocity_slider.setValue(preset['velocity']) + + print(f"Loaded {preset_name}") \ No newline at end of file diff --git a/gui/channel_controls.py b/gui/channel_controls.py new file mode 100644 index 0000000..224a668 --- /dev/null +++ b/gui/channel_controls.py @@ -0,0 +1,233 @@ +""" +Channel Controls GUI + +Interface for managing MIDI channels, instruments, and synth configuration. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSpinBox, QLabel, QPushButton, + QScrollArea, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class ChannelControls(QWidget): + """Control panel for MIDI channel management""" + + def __init__(self, channel_manager, output_manager): + super().__init__() + self.channel_manager = channel_manager + self.output_manager = output_manager + self.channel_widgets = {} + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Global Settings + global_group = self.create_global_settings() + layout.addWidget(global_group) + + # Individual Channel Settings + channels_group = self.create_channel_settings() + layout.addWidget(channels_group) + + def create_global_settings(self) -> QGroupBox: + """Create global channel settings""" + group = QGroupBox("Global Settings") + layout = QGridLayout(group) + + # Active Synth Count + layout.addWidget(QLabel("Active Synths:"), 0, 0) + self.synth_count_spin = QSpinBox() + self.synth_count_spin.setRange(1, 16) + self.synth_count_spin.setValue(8) + layout.addWidget(self.synth_count_spin, 0, 1) + + # Global Instrument + layout.addWidget(QLabel("Global Instrument:"), 1, 0) + global_layout = QHBoxLayout() + self.global_instrument_combo = QComboBox() + self.populate_instrument_combo(self.global_instrument_combo) + self.apply_global_button = QPushButton("Apply to All") + global_layout.addWidget(self.global_instrument_combo) + global_layout.addWidget(self.apply_global_button) + layout.addLayout(global_layout, 1, 1) + + return group + + def create_channel_settings(self) -> QGroupBox: + """Create individual channel settings""" + group = QGroupBox("Individual Channels") + layout = QVBoxLayout(group) + + # Scroll area for channel controls + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setMaximumHeight(400) + + # Widget to contain all channel controls + scroll_widget = QWidget() + scroll_layout = QVBoxLayout(scroll_widget) + + # Create controls for each channel + for channel in range(1, 17): + channel_widget = self.create_single_channel_control(channel) + scroll_layout.addWidget(channel_widget) + self.channel_widgets[channel] = channel_widget + + scroll.setWidget(scroll_widget) + layout.addWidget(scroll) + + return group + + def create_single_channel_control(self, channel: int) -> QFrame: + """Create controls for a single channel""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + layout = QHBoxLayout(frame) + + # Channel label + channel_label = QLabel(f"Ch {channel}:") + channel_label.setFixedWidth(40) + channel_label.setStyleSheet("font-weight: bold;") + layout.addWidget(channel_label) + + # Instrument selection + instrument_combo = QComboBox() + instrument_combo.setFixedWidth(200) + self.populate_instrument_combo(instrument_combo) + layout.addWidget(instrument_combo) + + # Voice count display + voice_label = QLabel("Voices: 0/3") + voice_label.setFixedWidth(80) + layout.addWidget(voice_label) + + # Status indicator + status_label = QLabel("●") + status_label.setStyleSheet("color: #666666; font-size: 16px;") + status_label.setFixedWidth(20) + layout.addWidget(status_label) + + layout.addStretch() + + # Store references for easy access + frame.instrument_combo = instrument_combo + frame.voice_label = voice_label + frame.status_label = status_label + frame.channel = channel + + # Connect instrument change + instrument_combo.currentIndexChanged.connect( + lambda idx, ch=channel: self.on_channel_instrument_changed(ch, idx) + ) + + return frame + + def populate_instrument_combo(self, combo: QComboBox): + """Populate combo box with GM instruments""" + for i, name in enumerate(self.channel_manager.GM_PROGRAMS): + combo.addItem(f"{i:03d}: {name}", i) + + def connect_signals(self): + """Connect signals and slots""" + # Global settings + self.synth_count_spin.valueChanged.connect(self.on_synth_count_changed) + self.apply_global_button.clicked.connect(self.on_apply_global_instrument) + + # Channel manager signals + self.channel_manager.active_synth_count_changed.connect(self.on_active_count_changed) + self.channel_manager.channel_instrument_changed.connect(self.on_instrument_changed) + self.channel_manager.voice_allocation_changed.connect(self.on_voice_allocation_changed) + + @pyqtSlot(int) + def on_synth_count_changed(self, count): + """Handle active synth count change""" + self.channel_manager.set_active_synth_count(count) + self.update_channel_visibility() + + @pyqtSlot() + def on_apply_global_instrument(self): + """Apply global instrument to all active channels""" + program = self.global_instrument_combo.currentData() + if program is not None: + self.channel_manager.set_all_instruments(program) + # Send program changes via output manager + for channel in self.channel_manager.get_active_channels(): + self.output_manager.send_program_change(channel, program) + + @pyqtSlot(int, int) + def on_channel_instrument_changed(self, channel, combo_index): + """Handle individual channel instrument change""" + combo = self.channel_widgets[channel].instrument_combo + program = combo.itemData(combo_index) + if program is not None: + self.channel_manager.set_channel_instrument(channel, program) + self.output_manager.send_program_change(channel, program) + + @pyqtSlot(int) + def on_active_count_changed(self, count): + """Handle active synth count change from channel manager""" + self.synth_count_spin.setValue(count) + self.update_channel_visibility() + + @pyqtSlot(int, int) + def on_instrument_changed(self, channel, program): + """Handle instrument change from channel manager""" + if channel in self.channel_widgets: + combo = self.channel_widgets[channel].instrument_combo + # Find and select the correct item + for i in range(combo.count()): + if combo.itemData(i) == program: + combo.setCurrentIndex(i) + break + + @pyqtSlot(int, list) + def on_voice_allocation_changed(self, channel, active_notes): + """Handle voice allocation change""" + if channel in self.channel_widgets: + voice_count = len(active_notes) + max_voices = self.channel_manager.max_voices_per_synth + voice_label = self.channel_widgets[channel].voice_label + voice_label.setText(f"Voices: {voice_count}/{max_voices}") + + # Update status indicator + status_label = self.channel_widgets[channel].status_label + if voice_count > 0: + if voice_count >= max_voices: + status_label.setStyleSheet("color: #aa6600; font-size: 16px;") # Orange - full + else: + status_label.setStyleSheet("color: #00aa00; font-size: 16px;") # Green - active + else: + status_label.setStyleSheet("color: #666666; font-size: 16px;") # Gray - inactive + + def update_channel_visibility(self): + """Update visibility of channel controls based on active count""" + active_count = self.channel_manager.active_synth_count + + for channel, widget in self.channel_widgets.items(): + if channel <= active_count: + widget.show() + widget.setStyleSheet("") # Active appearance + else: + widget.hide() + # Could also use different styling instead of hiding + # widget.setStyleSheet("color: #666666;") # Grayed out + + def refresh_all_channels(self): + """Refresh all channel displays""" + for channel in range(1, 17): + if channel in self.channel_widgets: + # Update instrument display + program = self.channel_manager.get_channel_instrument(channel) + if program is not None: + combo = self.channel_widgets[channel].instrument_combo + for i in range(combo.count()): + if combo.itemData(i) == program: + combo.setCurrentIndex(i) + break + + # Update voice count + voices = self.channel_manager.get_active_voices(channel) + self.on_voice_allocation_changed(channel, voices) \ No newline at end of file diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..926d7d8 --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,628 @@ +""" +Main Window GUI + +Primary application window with all controls and displays. +Integrates all GUI components into a cohesive interface. +""" + +from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QGridLayout, QPushButton, QLabel, QSlider, QComboBox, + QSpinBox, QGroupBox, QTabWidget, QSplitter, QFrame) +from PyQt5.QtCore import Qt, QTimer, pyqtSlot +from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence + +from .arpeggiator_controls import ArpeggiatorControls +from .channel_controls import ChannelControls +from .volume_controls import VolumeControls +from .simulator_display import SimulatorDisplay +from .output_controls import OutputControls +from .preset_controls import PresetControls + +class MainWindow(QMainWindow): + """ + Main application window containing all GUI components. + Provides organized layout and coordinates between different control panels. + """ + + def __init__(self, arpeggiator, channel_manager, volume_engine, output_manager, simulator, maschine_controller=None): + super().__init__() + + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + self.output_manager = output_manager + self.simulator = simulator + self.maschine_controller = maschine_controller + + self.setWindowTitle("MIDI Arpeggiator - Lighting Controller") + self.setMinimumSize(1200, 800) + + # Keyboard note mapping + self.keyboard_notes = { + Qt.Key_A: 60, # C + Qt.Key_W: 61, # C# + Qt.Key_S: 62, # D + Qt.Key_E: 63, # D# + Qt.Key_D: 64, # E + Qt.Key_F: 65, # F + Qt.Key_T: 66, # F# + Qt.Key_G: 67, # G + Qt.Key_Y: 68, # G# + Qt.Key_H: 69, # A + Qt.Key_U: 70, # A# + Qt.Key_J: 71, # B + Qt.Key_K: 72, # C (next octave) + } + self.held_keys = set() + + self.setup_ui() + self.setup_connections() + self.apply_dark_theme() + + def setup_ui(self): + """Initialize the user interface""" + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Create main layout with full-window tabs + main_layout = QVBoxLayout(central_widget) + + # Transport controls at top + transport_frame = self.create_transport_controls() + main_layout.addWidget(transport_frame) + + # Create tabbed interface that fills the window + tab_widget = QTabWidget() + main_layout.addWidget(tab_widget) + + # Arpeggiator tab with quadrant layout + self.arp_controls = ArpeggiatorControls(self.arpeggiator, self.channel_manager, self.simulator) + tab_widget.addTab(self.arp_controls, "Arpeggiator") + + # Channels tab + self.channel_controls = ChannelControls(self.channel_manager, self.output_manager) + tab_widget.addTab(self.channel_controls, "Channels") + + # Volume/Lighting tab + self.volume_controls = VolumeControls(self.volume_engine) + tab_widget.addTab(self.volume_controls, "Volume/Lighting") + + # Output tab + self.output_controls = OutputControls(self.output_manager) + tab_widget.addTab(self.output_controls, "Output") + + # Simulator display now integrated into arpeggiator tab - removed standalone tab + + # Presets tab + self.preset_controls = PresetControls(self.arpeggiator, self.channel_manager, self.volume_engine) + tab_widget.addTab(self.preset_controls, "Presets") + + # Status display at bottom + status_frame = self.create_status_display() + main_layout.addWidget(status_frame) + + # Create status bar + self.statusBar().showMessage("Ready - Use keyboard (AWSDFGTGHYUJ) to play notes, SPACE to start/stop") + + # Create menu bar + self.create_menu_bar() + + # Removed create_control_panel and create_display_panel methods - now using direct tab layout + + def create_transport_controls(self) -> QFrame: + """Create transport control buttons""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + frame.setMaximumHeight(60) # Limit height to just accommodate buttons + layout = QHBoxLayout(frame) + + # Play/Stop buttons + self.play_button = QPushButton("▶ PLAY") + self.play_button.setObjectName("playButton") + self.stop_button = QPushButton("⏹ STOP") + self.stop_button.setObjectName("stopButton") + self.panic_button = QPushButton("⚠ PANIC") + self.panic_button.setObjectName("panicButton") + + # Style buttons + button_style = """ + QPushButton { + font-size: 14px; + font-weight: bold; + padding: 10px 20px; + border-radius: 5px; + } + QPushButton:hover { + background-color: #404040; + } + """ + + play_style = button_style + """ + QPushButton { + background-color: #2d5a2d; + color: white; + } + """ + + stop_style = button_style + """ + QPushButton { + background-color: #5a2d2d; + color: white; + } + """ + + panic_style = button_style + """ + QPushButton { + background-color: #5a2d5a; + color: white; + } + """ + + self.play_button.setStyleSheet(play_style) + self.stop_button.setStyleSheet(stop_style) + self.panic_button.setStyleSheet(panic_style) + + # Connect buttons + self.play_button.clicked.connect(self.on_play_clicked) + self.stop_button.clicked.connect(self.on_stop_clicked) + self.panic_button.clicked.connect(self.on_panic_clicked) + + # Add to layout + layout.addWidget(self.play_button) + layout.addWidget(self.stop_button) + layout.addWidget(self.panic_button) + layout.addStretch() + + # Tempo display + tempo_label = QLabel("Tempo:") + self.tempo_display = QLabel("120 BPM") + self.tempo_display.setStyleSheet("font-weight: bold; font-size: 14px;") + + layout.addWidget(tempo_label) + layout.addWidget(self.tempo_display) + + return frame + + def create_status_display(self) -> QFrame: + """Create status information display""" + frame = QFrame() + frame.setFrameStyle(QFrame.Box) + frame.setMaximumHeight(60) + layout = QHBoxLayout(frame) + + # Output mode indicator + self.mode_indicator = QLabel("Mode: Simulator") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;") + + # Connection status + self.connection_status = QLabel("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + + # Active voices count + self.voices_display = QLabel("Voices: 0") + + layout.addWidget(QLabel("Status:")) + layout.addWidget(self.mode_indicator) + layout.addWidget(QFrame()) # Separator + layout.addWidget(self.connection_status) + layout.addWidget(QFrame()) # Separator + layout.addWidget(self.voices_display) + layout.addStretch() + + return frame + + def create_menu_bar(self): + """Create application menu bar""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu('File') + file_menu.addAction('New Preset', self.preset_controls.new_preset) + file_menu.addAction('Load Preset', self.preset_controls.load_preset) + file_menu.addAction('Save Preset', self.preset_controls.save_preset) + file_menu.addSeparator() + file_menu.addAction('Exit', self.close) + + # View menu + view_menu = menubar.addMenu('View') + view_menu.addAction('Reset Layout', self.reset_layout) + + # MIDI menu + midi_menu = menubar.addMenu('MIDI') + midi_menu.addAction('Refresh Devices', self.output_controls.refresh_midi_devices) + midi_menu.addAction('Panic (All Notes Off)', self.on_panic_clicked) + + # Help menu + help_menu = menubar.addMenu('Help') + help_menu.addAction('About', self.show_about) + + def setup_connections(self): + """Connect signals and slots""" + # Arpeggiator signals + self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed) + self.arpeggiator.tempo_changed.connect(self.on_tempo_changed) + + # Output manager signals + self.output_manager.mode_changed.connect(self.on_output_mode_changed) + + # Simulator signals now only connected to embedded display in arpeggiator controls + + # Connect signals to embedded simulator display in arpeggiator controls + if hasattr(self.arp_controls, 'simulator_display'): + self.channel_manager.active_synth_count_changed.connect( + self.arp_controls.simulator_display.set_synth_count + ) + # Disabled lighting_updated connection - using MIDI volume changes instead + # self.simulator.lighting_updated.connect( + # self.arp_controls.simulator_display.update_lighting + # ) + self.arpeggiator.note_triggered.connect( + self.arp_controls.simulator_display.on_note_played + ) + # Connect MIDI volume changes to display + self.output_manager.volume_sent.connect( + self.arp_controls.simulator_display.on_midi_volume_changed + ) + + # Initialize display with current volume values + for channel in range(1, 17): + current_volume = self.output_manager.get_channel_volume(channel) + self.arp_controls.simulator_display.on_midi_volume_changed(channel, current_volume) + + # Update timer for status display + self.status_timer = QTimer() + self.status_timer.timeout.connect(self.update_status_display) + self.status_timer.start(100) # Update 10 times per second + + def apply_dark_theme(self): + """Apply modern dark theme optimized for live performance""" + dark_palette = QPalette() + + # Modern dark colors + dark_palette.setColor(QPalette.Window, QColor(32, 32, 36)) + dark_palette.setColor(QPalette.WindowText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Base, QColor(18, 18, 20)) + dark_palette.setColor(QPalette.AlternateBase, QColor(42, 42, 46)) + dark_palette.setColor(QPalette.ToolTipBase, QColor(0, 0, 0)) + dark_palette.setColor(QPalette.ToolTipText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Text, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.Button, QColor(48, 48, 52)) + dark_palette.setColor(QPalette.ButtonText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.BrightText, QColor(255, 100, 100)) + dark_palette.setColor(QPalette.Link, QColor(100, 200, 255)) + dark_palette.setColor(QPalette.Highlight, QColor(0, 150, 255)) + dark_palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + + self.setPalette(dark_palette) + + # Modern performance-oriented styling + self.setStyleSheet(""" + /* Main Window */ + QMainWindow { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #202024, stop:1 #181820); + } + + /* Tabs - More prominent for live use */ + QTabWidget::pane { + border: 2px solid #00aaff; + border-radius: 8px; + background-color: #2a2a2e; + } + QTabBar::tab { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #404044, stop:1 #353538); + color: white; + padding: 12px 20px; + margin: 2px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + min-width: 100px; + font-size: 12px; + font-weight: bold; + } + QTabBar::tab:selected { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #0099ff, stop:1 #0077cc); + color: white; + border: 2px solid #00aaff; + } + QTabBar::tab:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #505054, stop:1 #454548); + } + + /* Group Boxes - Better organization */ + QGroupBox { + font-weight: bold; + font-size: 13px; + color: #ffffff; + border: 2px solid #00aaff; + border-radius: 10px; + margin-top: 20px; + padding-top: 15px; + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #2a2a2e, stop:1 #242428); + } + QGroupBox::title { + subcontrol-origin: margin; + left: 15px; + padding: 0 10px 0 10px; + color: #00aaff; + background-color: #2a2a2e; + border-radius: 5px; + } + + /* Buttons - Individual styling only */ + QPushButton { + color: white; + font-weight: bold; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #505054, stop:1 #454548); + border: 2px solid #00aaff; + } + QPushButton:pressed { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #353538, stop:1 #2a2a2e); + border: 2px solid #0088cc; + } + + /* Combo Boxes - Cleaner look */ + QComboBox { + background: #353538; + border: 2px solid #555555; + border-radius: 5px; + color: white; + padding: 5px 10px; + min-width: 120px; + } + QComboBox:hover { + border: 2px solid #00aaff; + } + QComboBox::drop-down { + border: none; + width: 20px; + } + QComboBox::down-arrow { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid white; + margin-right: 5px; + } + + /* Sliders - Better for real-time control */ + QSlider::groove:horizontal { + border: 1px solid #555555; + height: 8px; + background: #2a2a2e; + border-radius: 4px; + } + QSlider::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #00aaff, stop:1 #0088cc); + border: 2px solid #ffffff; + width: 20px; + margin: -8px 0; + border-radius: 10px; + } + QSlider::handle:horizontal:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #00ccff, stop:1 #00aacc); + } + + /* Spin Boxes */ + QSpinBox { + background: #353538; + border: 2px solid #555555; + border-radius: 5px; + color: white; + padding: 5px; + min-width: 60px; + } + QSpinBox:hover { + border: 2px solid #00aaff; + } + + /* Labels - Better contrast */ + QLabel { + color: #ffffff; + font-size: 11px; + } + + /* Status Bar - More prominent */ + QStatusBar { + background: #1a1a1e; + color: #00aaff; + border-top: 1px solid #555555; + font-weight: bold; + padding: 5px; + } + + /* Transport Controls - Special styling */ + QPushButton#playButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #2d5a2d, stop:1 #1a3d1a); + border: 2px solid #4a8a4a; + color: #aaffaa; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#playButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #3d6a3d, stop:1 #2a4d2a); + border: 2px solid #5aaa5a; + } + + QPushButton#stopButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5a2d2d, stop:1 #3d1a1a); + border: 2px solid #8a4a4a; + color: #ffaaaa; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#stopButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #6a3d3d, stop:1 #4d2a2a); + border: 2px solid #aa5a5a; + } + + QPushButton#panicButton { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #5a2d5a, stop:1 #3d1a3d); + border: 2px solid #8a4a8a; + color: #ffaaff; + font-size: 16px; + font-weight: bold; + min-width: 100px; + min-height: 40px; + } + QPushButton#panicButton:hover { + background: qlineargradient(x1:0, y1:0, x2:0, y2:1, + stop:0 #6a3d6a, stop:1 #4d2a4d); + border: 2px solid #aa5aaa; + } + """) + + @pyqtSlot() + def on_play_clicked(self): + """Handle play button click""" + # If no notes are held, add some default notes for testing + if not self.arpeggiator.held_notes: + # Add a C major chord for testing + self.arpeggiator.note_on(60) # C + self.arpeggiator.note_on(64) # E + self.arpeggiator.note_on(67) # G + self.statusBar().showMessage("Added test notes (C major chord)", 2000) + + success = self.arpeggiator.start() + if not success: + self.statusBar().showMessage("Could not start arpeggiator", 3000) + + @pyqtSlot() + def on_stop_clicked(self): + """Handle stop button click""" + self.arpeggiator.stop() + + @pyqtSlot() + def on_panic_clicked(self): + """Handle panic button click""" + self.output_manager.send_panic() + self.statusBar().showMessage("Panic sent - all notes off", 2000) + + @pyqtSlot(bool) + def on_playing_state_changed(self, is_playing): + """Handle arpeggiator play state change""" + if is_playing: + self.play_button.setText("⏸ Pause") + self.statusBar().showMessage("Arpeggiator playing") + else: + self.play_button.setText("▶ Play") + self.statusBar().showMessage("Arpeggiator stopped") + + @pyqtSlot(float) + def on_tempo_changed(self, tempo): + """Handle tempo change""" + self.tempo_display.setText(f"{tempo:.1f} BPM") + + @pyqtSlot(str) + def on_output_mode_changed(self, mode): + """Handle output mode change""" + if mode == "simulator": + self.mode_indicator.setText("Mode: Simulator") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #00aa00;") + else: + self.mode_indicator.setText("Mode: Hardware") + self.mode_indicator.setStyleSheet("font-weight: bold; color: #aaaa00;") + + def update_status_display(self): + """Update status display information""" + # Update connection status + if self.output_manager.is_connected(): + self.connection_status.setText("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + else: + self.connection_status.setText("Disconnected") + self.connection_status.setStyleSheet("color: #aa0000;") + + # Update active voices count + if self.output_manager.current_mode == "simulator": + voice_count = self.simulator.get_active_voices_count() + else: + voice_count = sum(len(voices) for voices in self.channel_manager.active_voices.values()) + + self.voices_display.setText(f"Voices: {voice_count}") + + def reset_layout(self): + """Reset window layout to default""" + # This could restore default sizes, positions, etc. + self.statusBar().showMessage("Layout reset", 2000) + + def show_about(self): + """Show about dialog""" + from PyQt5.QtWidgets import QMessageBox + QMessageBox.about(self, "About MIDI Arpeggiator", + "MIDI Arpeggiator with Lighting Control\n\n" + "A modular arpeggiator for controlling synthesizers\n" + "and synchronized lighting effects.\n\n" + "Features:\n" + "• FL Studio-style arpeggiator patterns\n" + "• Multi-synth routing and voice management\n" + "• Volume/brightness pattern generation\n" + "• Built-in simulator mode\n" + "• Native Instruments Maschine integration") + + def keyPressEvent(self, event): + """Handle key press for note input""" + key = event.key() + + # Avoid key repeat + if event.isAutoRepeat(): + return + + if key in self.keyboard_notes: + note = self.keyboard_notes[key] + if note not in self.held_keys: + self.held_keys.add(note) + self.arpeggiator.note_on(note) + self.statusBar().showMessage(f"Note ON: {note}", 500) + + elif key == Qt.Key_Space: + # Spacebar starts/stops arpeggiator + if self.arpeggiator.is_playing: + self.on_stop_clicked() + else: + self.on_play_clicked() + + super().keyPressEvent(event) + + def keyReleaseEvent(self, event): + """Handle key release for note input""" + key = event.key() + + # Avoid key repeat + if event.isAutoRepeat(): + return + + if key in self.keyboard_notes: + note = self.keyboard_notes[key] + if note in self.held_keys: + self.held_keys.remove(note) + self.arpeggiator.note_off(note) + self.statusBar().showMessage(f"Note OFF: {note}", 500) + + super().keyReleaseEvent(event) + + def closeEvent(self, event): + """Handle window close event""" + # Clean up resources + self.arpeggiator.stop() + self.output_manager.close() + self.simulator.cleanup() + event.accept() \ No newline at end of file diff --git a/gui/output_controls.py b/gui/output_controls.py new file mode 100644 index 0000000..e3a3932 --- /dev/null +++ b/gui/output_controls.py @@ -0,0 +1,268 @@ +""" +Output Controls GUI + +Interface for managing MIDI output mode and device selection. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QPushButton, QLabel, + QRadioButton, QButtonGroup, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot + +class OutputControls(QWidget): + """Control panel for MIDI output management""" + + def __init__(self, output_manager): + super().__init__() + self.output_manager = output_manager + self.setup_ui() + self.connect_signals() + self.refresh_midi_devices() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Output Mode Selection + mode_group = self.create_mode_selection() + layout.addWidget(mode_group) + + # Hardware MIDI Settings + midi_group = self.create_midi_settings() + layout.addWidget(midi_group) + + # Status and Controls + status_group = self.create_status_controls() + layout.addWidget(status_group) + + layout.addStretch() + + def create_mode_selection(self) -> QGroupBox: + """Create output mode selection controls""" + group = QGroupBox("Output Mode") + layout = QVBoxLayout(group) + + # Radio buttons for mode selection + self.mode_group = QButtonGroup() + + self.simulator_radio = QRadioButton("Simulator Mode") + self.simulator_radio.setChecked(True) # Default + self.simulator_radio.setToolTip("Use internal audio synthesis and visual lighting simulation") + + self.hardware_radio = QRadioButton("Hardware Mode") + self.hardware_radio.setToolTip("Send MIDI to external hardware synths") + + self.mode_group.addButton(self.simulator_radio, 0) + self.mode_group.addButton(self.hardware_radio, 1) + + layout.addWidget(self.simulator_radio) + layout.addWidget(self.hardware_radio) + + # Mode description + self.mode_description = QLabel("Using internal simulator with audio synthesis and lighting visualization.") + self.mode_description.setWordWrap(True) + self.mode_description.setStyleSheet("color: #888888; font-style: italic;") + layout.addWidget(self.mode_description) + + return group + + def create_midi_settings(self) -> QGroupBox: + """Create MIDI device settings""" + group = QGroupBox("Hardware MIDI Settings") + layout = QGridLayout(group) + + # MIDI Output Device + layout.addWidget(QLabel("MIDI Output:"), 0, 0) + self.midi_device_combo = QComboBox() + self.midi_device_combo.setMinimumWidth(200) + layout.addWidget(self.midi_device_combo, 0, 1) + + self.refresh_button = QPushButton("Refresh") + self.refresh_button.clicked.connect(self.refresh_midi_devices) + layout.addWidget(self.refresh_button, 0, 2) + + # Connection status + layout.addWidget(QLabel("Status:"), 1, 0) + self.connection_status = QLabel("Not Connected") + self.connection_status.setStyleSheet("color: #aa6600;") + layout.addWidget(self.connection_status, 1, 1) + + # Initially disable MIDI settings (simulator mode default) + self.set_midi_controls_enabled(False) + + return group + + def create_status_controls(self) -> QGroupBox: + """Create status display and control buttons""" + group = QGroupBox("Controls") + layout = QGridLayout(group) + + # Panic button + self.panic_button = QPushButton("🚨 Panic (All Notes Off)") + self.panic_button.setStyleSheet(""" + QPushButton { + background-color: #5a2d5a; + color: white; + font-weight: bold; + padding: 8px; + border-radius: 4px; + } + QPushButton:hover { + background-color: #6a3d6a; + } + """) + layout.addWidget(self.panic_button, 0, 0, 1, 2) + + # Test button + self.test_button = QPushButton("Test Output") + self.test_button.setToolTip("Send a test note to verify output is working") + layout.addWidget(self.test_button, 1, 0) + + # Status display + status_frame = QFrame() + status_frame.setFrameStyle(QFrame.Box) + status_layout = QVBoxLayout(status_frame) + + self.output_info = QLabel("Mode: Simulator\\nDevice: Internal\\nStatus: Ready") + self.output_info.setStyleSheet("font-family: monospace;") + status_layout.addWidget(self.output_info) + + layout.addWidget(status_frame, 2, 0, 1, 2) + + return group + + def connect_signals(self): + """Connect signals and slots""" + # Mode selection + self.mode_group.buttonClicked.connect(self.on_mode_changed) + + # MIDI device selection + self.midi_device_combo.currentTextChanged.connect(self.on_midi_device_changed) + + # Control buttons + self.panic_button.clicked.connect(self.on_panic_clicked) + self.test_button.clicked.connect(self.on_test_clicked) + + # Output manager signals + self.output_manager.mode_changed.connect(self.on_output_mode_changed) + self.output_manager.midi_device_changed.connect(self.on_output_device_changed) + self.output_manager.error_occurred.connect(self.on_output_error) + + def set_midi_controls_enabled(self, enabled: bool): + """Enable/disable MIDI hardware controls""" + self.midi_device_combo.setEnabled(enabled) + self.refresh_button.setEnabled(enabled) + + if enabled: + self.connection_status.setText("Ready for connection") + self.connection_status.setStyleSheet("color: #aaaa00;") + else: + self.connection_status.setText("Simulator Mode") + self.connection_status.setStyleSheet("color: #00aa00;") + + @pyqtSlot() + def refresh_midi_devices(self): + """Refresh MIDI device list""" + self.output_manager.refresh_midi_devices() + devices = self.output_manager.get_available_outputs() + + self.midi_device_combo.clear() + if devices: + for device in devices: + self.midi_device_combo.addItem(device) + else: + self.midi_device_combo.addItem("No MIDI devices found") + + # Update status + self.update_status_display() + + @pyqtSlot() + def on_mode_changed(self): + """Handle output mode change""" + if self.simulator_radio.isChecked(): + self.output_manager.set_mode("simulator") + self.set_midi_controls_enabled(False) + self.mode_description.setText( + "Using internal simulator with audio synthesis and lighting visualization." + ) + else: + self.output_manager.set_mode("hardware") + self.set_midi_controls_enabled(True) + self.mode_description.setText( + "Sending MIDI to external hardware synthesizers. " + "Select MIDI output device below." + ) + + @pyqtSlot(str) + def on_midi_device_changed(self, device_name: str): + """Handle MIDI device selection change""" + if device_name and device_name != "No MIDI devices found": + success = self.output_manager.set_midi_output(device_name) + if success: + self.connection_status.setText("Connected") + self.connection_status.setStyleSheet("color: #00aa00;") + else: + self.connection_status.setText("Connection Failed") + self.connection_status.setStyleSheet("color: #aa0000;") + + self.update_status_display() + + @pyqtSlot() + def on_panic_clicked(self): + """Handle panic button click""" + self.output_manager.send_panic() + + @pyqtSlot() + def on_test_clicked(self): + """Handle test button click""" + # Send a test note (Middle C for 500ms) + self.output_manager.send_note_on(1, 60, 80) # Channel 1, Middle C, velocity 80 + + # Schedule note off after 500ms + from PyQt5.QtCore import QTimer + QTimer.singleShot(500, lambda: self.output_manager.send_note_off(1, 60)) + + @pyqtSlot(str) + def on_output_mode_changed(self, mode: str): + """Handle mode change from output manager""" + if mode == "simulator": + self.simulator_radio.setChecked(True) + else: + self.hardware_radio.setChecked(True) + + self.update_status_display() + + @pyqtSlot(str) + def on_output_device_changed(self, device: str): + """Handle device change from output manager""" + # Find and select the device in combo box + index = self.midi_device_combo.findText(device) + if index >= 0: + self.midi_device_combo.setCurrentIndex(index) + + self.update_status_display() + + @pyqtSlot(str) + def on_output_error(self, error_message: str): + """Handle output error""" + self.connection_status.setText("Error") + self.connection_status.setStyleSheet("color: #aa0000;") + self.connection_status.setToolTip(error_message) + + def update_status_display(self): + """Update the status information display""" + status_info = self.output_manager.get_status_info() + + mode = status_info.get('mode', 'Unknown') + connected = status_info.get('connected', False) + device = status_info.get('selected_output', 'None') + + if mode == "simulator": + device_text = "Internal Simulator" + status_text = "Ready" + else: + device_text = device if device else "None Selected" + status_text = "Connected" if connected else "Disconnected" + + info_text = f"Mode: {mode.title()}\\nDevice: {device_text}\\nStatus: {status_text}" + self.output_info.setText(info_text) \ No newline at end of file diff --git a/gui/preset_controls.py b/gui/preset_controls.py new file mode 100644 index 0000000..fa77f62 --- /dev/null +++ b/gui/preset_controls.py @@ -0,0 +1,510 @@ +""" +Preset Controls GUI + +Interface for saving, loading, and managing presets. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QListWidget, QPushButton, QLineEdit, + QLabel, QFileDialog, QMessageBox, QListWidgetItem, + QInputDialog, QFrame) +from PyQt5.QtCore import Qt, pyqtSlot +import json +import os + +class PresetControls(QWidget): + """Control panel for preset management""" + + def __init__(self, arpeggiator, channel_manager, volume_engine): + super().__init__() + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + + # Preset storage + self.presets = {} + self.current_preset = None + self.presets_directory = "presets" + + # Ensure presets directory exists + os.makedirs(self.presets_directory, exist_ok=True) + + self.setup_ui() + self.load_presets_from_directory() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Preset list + preset_group = self.create_preset_list() + layout.addWidget(preset_group) + + # Preset operations + operations_group = self.create_operations() + layout.addWidget(operations_group) + + # File operations + file_group = self.create_file_operations() + layout.addWidget(file_group) + + def create_preset_list(self) -> QGroupBox: + """Create preset list display""" + group = QGroupBox("Presets") + layout = QVBoxLayout(group) + + self.preset_list = QListWidget() + self.preset_list.setMaximumHeight(200) + self.preset_list.itemClicked.connect(self.on_preset_selected) + self.preset_list.itemDoubleClicked.connect(self.on_preset_double_clicked) + layout.addWidget(self.preset_list) + + # Current preset indicator + current_layout = QHBoxLayout() + current_layout.addWidget(QLabel("Current:")) + self.current_preset_label = QLabel("None") + self.current_preset_label.setStyleSheet("font-weight: bold; color: #00aa00;") + current_layout.addWidget(self.current_preset_label) + current_layout.addStretch() + layout.addLayout(current_layout) + + return group + + def create_operations(self) -> QGroupBox: + """Create preset operation buttons""" + group = QGroupBox("Operations") + layout = QGridLayout(group) + + # Load preset + self.load_button = QPushButton("Load Preset") + self.load_button.setEnabled(False) + self.load_button.clicked.connect(self.load_selected_preset) + layout.addWidget(self.load_button, 0, 0) + + # Save current as new preset + self.save_new_button = QPushButton("Save as New...") + self.save_new_button.clicked.connect(self.save_new_preset) + layout.addWidget(self.save_new_button, 0, 1) + + # Update selected preset + self.update_button = QPushButton("Update Selected") + self.update_button.setEnabled(False) + self.update_button.clicked.connect(self.update_selected_preset) + layout.addWidget(self.update_button, 1, 0) + + # Delete preset + self.delete_button = QPushButton("Delete Selected") + self.delete_button.setEnabled(False) + self.delete_button.clicked.connect(self.delete_selected_preset) + self.delete_button.setStyleSheet("color: #aa6666;") + layout.addWidget(self.delete_button, 1, 1) + + # Rename preset + self.rename_button = QPushButton("Rename Selected") + self.rename_button.setEnabled(False) + self.rename_button.clicked.connect(self.rename_selected_preset) + layout.addWidget(self.rename_button, 2, 0) + + # Duplicate preset + self.duplicate_button = QPushButton("Duplicate Selected") + self.duplicate_button.setEnabled(False) + self.duplicate_button.clicked.connect(self.duplicate_selected_preset) + layout.addWidget(self.duplicate_button, 2, 1) + + return group + + def create_file_operations(self) -> QGroupBox: + """Create file operation buttons""" + group = QGroupBox("File Operations") + layout = QHBoxLayout(group) + + # Import preset + self.import_button = QPushButton("Import Preset...") + self.import_button.clicked.connect(self.import_preset) + layout.addWidget(self.import_button) + + # Export preset + self.export_button = QPushButton("Export Selected...") + self.export_button.setEnabled(False) + self.export_button.clicked.connect(self.export_selected_preset) + layout.addWidget(self.export_button) + + return group + + def capture_current_settings(self) -> dict: + """Capture current settings into a preset dictionary""" + preset = { + "version": "1.0", + "timestamp": None, # Will be set when saving + + # Arpeggiator settings + "arpeggiator": { + "root_note": self.arpeggiator.root_note, + "scale": self.arpeggiator.scale, + "pattern_type": self.arpeggiator.pattern_type, + "octave_range": self.arpeggiator.octave_range, + "note_speed": self.arpeggiator.note_speed, + "gate": self.arpeggiator.gate, + "swing": self.arpeggiator.swing, + "velocity": self.arpeggiator.velocity, + "tempo": self.arpeggiator.tempo + }, + + # Channel settings + "channels": { + "active_synth_count": self.channel_manager.active_synth_count, + "channel_instruments": self.channel_manager.channel_instruments.copy() + }, + + # Volume pattern settings + "volume_patterns": { + "current_pattern": self.volume_engine.current_pattern, + "pattern_speed": self.volume_engine.pattern_speed, + "pattern_intensity": self.volume_engine.pattern_intensity, + "global_volume_range": self.volume_engine.global_volume_range, + "global_velocity_range": self.volume_engine.global_velocity_range, + "channel_volume_ranges": self.volume_engine.channel_volume_ranges.copy(), + "velocity_ranges": self.volume_engine.velocity_ranges.copy() + } + } + + return preset + + def apply_preset_settings(self, preset: dict): + """Apply preset settings to the system""" + try: + # Apply arpeggiator settings + arp_settings = preset.get("arpeggiator", {}) + self.arpeggiator.set_root_note(arp_settings.get("root_note", 60)) + self.arpeggiator.set_scale(arp_settings.get("scale", "major")) + self.arpeggiator.set_pattern_type(arp_settings.get("pattern_type", "up")) + self.arpeggiator.set_octave_range(arp_settings.get("octave_range", 1)) + self.arpeggiator.set_note_speed(arp_settings.get("note_speed", "1/8")) + self.arpeggiator.set_gate(arp_settings.get("gate", 1.0)) + self.arpeggiator.set_swing(arp_settings.get("swing", 0.0)) + self.arpeggiator.set_velocity(arp_settings.get("velocity", 80)) + self.arpeggiator.set_tempo(arp_settings.get("tempo", 120.0)) + + # Apply channel settings + channel_settings = preset.get("channels", {}) + self.channel_manager.set_active_synth_count( + channel_settings.get("active_synth_count", 8) + ) + + # Apply instruments + instruments = channel_settings.get("channel_instruments", {}) + for channel_str, program in instruments.items(): + channel = int(channel_str) + self.channel_manager.set_channel_instrument(channel, program) + + # Apply volume pattern settings + volume_settings = preset.get("volume_patterns", {}) + self.volume_engine.set_pattern(volume_settings.get("current_pattern", "static")) + self.volume_engine.set_pattern_speed(volume_settings.get("pattern_speed", 1.0)) + self.volume_engine.set_pattern_intensity(volume_settings.get("pattern_intensity", 1.0)) + + # Apply global ranges + global_vol = volume_settings.get("global_volume_range", (0.2, 1.0)) + global_vel = volume_settings.get("global_velocity_range", (40, 127)) + self.volume_engine.set_global_ranges( + global_vol[0], global_vol[1], global_vel[0], global_vel[1] + ) + + # Apply individual channel ranges + ch_vol_ranges = volume_settings.get("channel_volume_ranges", {}) + for channel_str, range_tuple in ch_vol_ranges.items(): + channel = int(channel_str) + self.volume_engine.set_channel_volume_range(channel, range_tuple[0], range_tuple[1]) + + vel_ranges = volume_settings.get("velocity_ranges", {}) + for channel_str, range_tuple in vel_ranges.items(): + channel = int(channel_str) + self.volume_engine.set_velocity_range(channel, range_tuple[0], range_tuple[1]) + + except Exception as e: + QMessageBox.warning(self, "Preset Error", f"Error applying preset: {str(e)}") + + @pyqtSlot(QListWidgetItem) + def on_preset_selected(self, item): + """Handle preset selection""" + preset_name = item.text() + + # Enable/disable buttons based on selection + has_selection = preset_name is not None + self.load_button.setEnabled(has_selection) + self.update_button.setEnabled(has_selection) + self.delete_button.setEnabled(has_selection) + self.rename_button.setEnabled(has_selection) + self.duplicate_button.setEnabled(has_selection) + self.export_button.setEnabled(has_selection) + + @pyqtSlot(QListWidgetItem) + def on_preset_double_clicked(self, item): + """Handle preset double-click (load preset)""" + self.load_selected_preset() + + @pyqtSlot() + def load_selected_preset(self): + """Load the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + if preset_name in self.presets: + self.apply_preset_settings(self.presets[preset_name]) + self.current_preset = preset_name + self.current_preset_label.setText(preset_name) + + # Visual feedback + current_item.setBackground(Qt.darkGreen) + for i in range(self.preset_list.count()): + item = self.preset_list.item(i) + if item != current_item: + item.setBackground(Qt.transparent) + + @pyqtSlot() + def save_new_preset(self): + """Save current settings as a new preset""" + name, ok = QInputDialog.getText(self, "New Preset", "Enter preset name:") + if ok and name: + if name in self.presets: + reply = QMessageBox.question( + self, "Overwrite Preset", + f"Preset '{name}' already exists. Overwrite?", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + + # Capture current settings + preset_data = self.capture_current_settings() + preset_data["timestamp"] = self.get_current_timestamp() + + # Save preset + self.presets[name] = preset_data + self.save_preset_to_file(name, preset_data) + + # Update list + self.refresh_preset_list() + + # Select the new preset + items = self.preset_list.findItems(name, Qt.MatchExactly) + if items: + self.preset_list.setCurrentItem(items[0]) + + @pyqtSlot() + def update_selected_preset(self): + """Update the selected preset with current settings""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + reply = QMessageBox.question( + self, "Update Preset", + f"Update preset '{preset_name}' with current settings?", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + preset_data = self.capture_current_settings() + preset_data["timestamp"] = self.get_current_timestamp() + + self.presets[preset_name] = preset_data + self.save_preset_to_file(preset_name, preset_data) + + @pyqtSlot() + def delete_selected_preset(self): + """Delete the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + reply = QMessageBox.question( + self, "Delete Preset", + f"Delete preset '{preset_name}'? This cannot be undone.", + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # Remove from memory and file + if preset_name in self.presets: + del self.presets[preset_name] + + preset_file = os.path.join(self.presets_directory, f"{preset_name}.json") + if os.path.exists(preset_file): + os.remove(preset_file) + + # Update list + self.refresh_preset_list() + + # Clear current if it was deleted + if self.current_preset == preset_name: + self.current_preset = None + self.current_preset_label.setText("None") + + @pyqtSlot() + def rename_selected_preset(self): + """Rename the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + old_name = current_item.text() + new_name, ok = QInputDialog.getText(self, "Rename Preset", "Enter new name:", text=old_name) + + if ok and new_name and new_name != old_name: + if new_name in self.presets: + QMessageBox.warning(self, "Rename Error", f"Preset '{new_name}' already exists.") + return + + # Move preset data + self.presets[new_name] = self.presets[old_name] + del self.presets[old_name] + + # Handle files + old_file = os.path.join(self.presets_directory, f"{old_name}.json") + new_file = os.path.join(self.presets_directory, f"{new_name}.json") + + if os.path.exists(old_file): + os.rename(old_file, new_file) + + # Update current preset reference + if self.current_preset == old_name: + self.current_preset = new_name + self.current_preset_label.setText(new_name) + + # Refresh list + self.refresh_preset_list() + + @pyqtSlot() + def duplicate_selected_preset(self): + """Duplicate the selected preset""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + source_name = current_item.text() + new_name, ok = QInputDialog.getText(self, "Duplicate Preset", "Enter name for copy:", text=f"{source_name} Copy") + + if ok and new_name: + if new_name in self.presets: + QMessageBox.warning(self, "Duplicate Error", f"Preset '{new_name}' already exists.") + return + + # Copy preset data + self.presets[new_name] = self.presets[source_name].copy() + self.save_preset_to_file(new_name, self.presets[new_name]) + + # Refresh list and select new preset + self.refresh_preset_list() + items = self.preset_list.findItems(new_name, Qt.MatchExactly) + if items: + self.preset_list.setCurrentItem(items[0]) + + @pyqtSlot() + def import_preset(self): + """Import a preset from file""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Import Preset", "", "JSON Files (*.json);;All Files (*)" + ) + + if file_path: + try: + with open(file_path, 'r') as f: + preset_data = json.load(f) + + # Get name from user + default_name = os.path.splitext(os.path.basename(file_path))[0] + name, ok = QInputDialog.getText(self, "Import Preset", "Preset name:", text=default_name) + + if ok and name: + self.presets[name] = preset_data + self.save_preset_to_file(name, preset_data) + self.refresh_preset_list() + + except Exception as e: + QMessageBox.critical(self, "Import Error", f"Error importing preset: {str(e)}") + + @pyqtSlot() + def export_selected_preset(self): + """Export the selected preset to file""" + current_item = self.preset_list.currentItem() + if not current_item: + return + + preset_name = current_item.text() + + file_path, _ = QFileDialog.getSaveFileName( + self, "Export Preset", f"{preset_name}.json", "JSON Files (*.json);;All Files (*)" + ) + + if file_path: + try: + with open(file_path, 'w') as f: + json.dump(self.presets[preset_name], f, indent=2) + + except Exception as e: + QMessageBox.critical(self, "Export Error", f"Error exporting preset: {str(e)}") + + def load_presets_from_directory(self): + """Load all presets from the presets directory""" + if not os.path.exists(self.presets_directory): + return + + for filename in os.listdir(self.presets_directory): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + file_path = os.path.join(self.presets_directory, filename) + + try: + with open(file_path, 'r') as f: + preset_data = json.load(f) + self.presets[preset_name] = preset_data + except Exception as e: + print(f"Error loading preset {filename}: {e}") + + self.refresh_preset_list() + + def save_preset_to_file(self, name: str, preset_data: dict): + """Save a preset to file""" + file_path = os.path.join(self.presets_directory, f"{name}.json") + try: + with open(file_path, 'w') as f: + json.dump(preset_data, f, indent=2) + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Error saving preset: {str(e)}") + + def refresh_preset_list(self): + """Refresh the preset list display""" + self.preset_list.clear() + for name in sorted(self.presets.keys()): + item = QListWidgetItem(name) + if name == self.current_preset: + item.setBackground(Qt.darkGreen) + self.preset_list.addItem(item) + + def get_current_timestamp(self) -> str: + """Get current timestamp string""" + from datetime import datetime + return datetime.now().isoformat() + + def new_preset(self): + """Create a new preset (for menu action)""" + self.save_new_preset() + + def load_preset(self): + """Load preset (for menu action)""" + if self.preset_list.currentItem(): + self.load_selected_preset() + + def save_preset(self): + """Save preset (for menu action)""" + if self.preset_list.currentItem(): + self.update_selected_preset() + else: + self.save_new_preset() \ No newline at end of file diff --git a/gui/simulator_display.py b/gui/simulator_display.py new file mode 100644 index 0000000..49e3efc --- /dev/null +++ b/gui/simulator_display.py @@ -0,0 +1,173 @@ +""" +Simulator Display GUI + +Simplified visual representation showing only channel volumes. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QLabel, QFrame, QPushButton, QSlider, QGroupBox) +from PyQt5.QtCore import Qt, QTimer, pyqtSlot +from PyQt5.QtGui import QPainter, QColor, QBrush, QPen, QFont + +class SynthWidget(QFrame): + """Individual synth display widget - simplified to show only volume""" + + def __init__(self, channel: int): + super().__init__() + self.channel = channel + self.channel_volume_midi = 100 # 0-127 MIDI CC7 value + + self.setFrameStyle(QFrame.Box) + self.setFixedSize(100, 80) + self.setStyleSheet(""" + QFrame { + border: 2px solid #404040; + border-radius: 6px; + background-color: #2a2a2a; + } + """) + + def set_channel_volume(self, volume_midi: int): + """Set channel volume (0-127 MIDI CC7) - this is what controls brightness""" + self.channel_volume_midi = max(0, min(127, volume_midi)) + self.update() # Trigger repaint + + def paintEvent(self, event): + """Custom paint for volume-based lighting""" + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + # Calculate brightness based on MIDI volume (0-127) + brightness_intensity = int((self.channel_volume_midi / 127.0) * 255) + + # Create color based on channel (different hues) + hue = (self.channel - 1) * 360 / 16 # Distribute hues across spectrum + color = QColor.fromHsv(int(hue), 200, brightness_intensity + 20) + + # Draw volume-based lighting effect + if self.channel_volume_midi > 6: # Only show if volume is above ~5% (6/127) + # Draw volume glow + glow_rect = self.rect().adjusted(8, 8, -8, -8) + painter.setBrush(QBrush(color)) + painter.setPen(QPen(color.lighter(120), 1)) + painter.drawRoundedRect(glow_rect, 4, 4) + + # Draw channel info + painter.setPen(QPen(QColor(255, 255, 255), 1)) + painter.setFont(QFont("Arial", 11, QFont.Bold)) + + # Channel number + painter.drawText(10, 20, f"Ch {self.channel}") + + # Volume level (MIDI CC7 value) + painter.setFont(QFont("Arial", 9)) + painter.drawText(10, 65, f"Vol: {self.channel_volume_midi}") + + +class SimulatorDisplay(QWidget): + """Simplified simulator display widget""" + + def __init__(self, simulator, channel_manager): + super().__init__() + self.simulator = simulator + self.channel_manager = channel_manager + self.synth_widgets = {} + self.current_synth_count = 8 + + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Title + title = QLabel("Synth Array - Channel Volume Display") + title.setStyleSheet("font-size: 14px; font-weight: bold; color: #ffffff; margin: 5px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # Synth display grid + self.synth_grid_widget = QWidget() + self.synth_grid_layout = QGridLayout(self.synth_grid_widget) + self.synth_grid_layout.setSpacing(5) + layout.addWidget(self.synth_grid_widget) + + # Initialize with default synth count + self.create_synth_widgets(self.current_synth_count) + + def connect_signals(self): + """Connect signals""" + # Channel manager signals + if hasattr(self.channel_manager, 'volume_changed'): + self.channel_manager.volume_changed.connect(self.on_channel_volume_changed) + + def create_synth_widgets(self, count: int): + """Create synth widgets for display""" + # Clear existing widgets + for widget in self.synth_widgets.values(): + widget.deleteLater() + self.synth_widgets.clear() + + # Clear layout + while self.synth_grid_layout.count(): + child = self.synth_grid_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + # Create new widgets + cols = 4 # 4 synths per row + for i in range(count): + channel = i + 1 + widget = SynthWidget(channel) + + row = i // cols + col = i % cols + self.synth_grid_layout.addWidget(widget, row, col) + + self.synth_widgets[channel] = widget + + self.current_synth_count = count + + @pyqtSlot(int) + def set_synth_count(self, count: int): + """Set number of synths to display""" + if count != self.current_synth_count: + self.create_synth_widgets(count) + + @pyqtSlot(int, float) + def on_channel_volume_changed(self, channel: int, volume: float): + """Handle channel volume change""" + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) + + @pyqtSlot(int, int, int, float) + def on_note_played(self, channel: int, note: int, velocity: int, duration: float): + """Handle note played - we don't need special handling for this in volume mode""" + # In the simplified version, we only care about volume, not individual notes + pass + + @pyqtSlot(int, int) + def on_midi_volume_changed(self, channel: int, volume: int): + """Handle MIDI volume change (CC7) - channel 1-16, volume 0-127""" + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) + + @pyqtSlot(int, float, float) + @pyqtSlot(dict) + def update_lighting(self, lighting_data: dict): + """Update lighting based on channel volume from simulator engine""" + for channel, brightness in lighting_data.items(): + if channel in self.synth_widgets: + # Convert brightness (0-1) to MIDI (0-127) for display consistency + midi_volume = int(brightness * 127) + self.synth_widgets[channel].set_channel_volume(midi_volume) + + def update_channel_volumes_from_output_manager(self, output_manager): + """Update all channel volumes from output manager""" + for channel in range(1, self.current_synth_count + 1): + volume = output_manager.get_channel_volume(channel) / 127.0 # Convert from MIDI to 0-1 + if channel in self.synth_widgets: + self.synth_widgets[channel].set_channel_volume(volume) \ No newline at end of file diff --git a/gui/volume_controls.py b/gui/volume_controls.py new file mode 100644 index 0000000..a175f16 --- /dev/null +++ b/gui/volume_controls.py @@ -0,0 +1,317 @@ +""" +Volume Controls GUI + +Interface for tempo-linked volume and brightness pattern controls. +""" + +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QGroupBox, QComboBox, QSlider, QSpinBox, QLabel, + QPushButton, QFrame, QScrollArea) +from PyQt5.QtCore import Qt, pyqtSlot + +class VolumeControls(QWidget): + """Control panel for tempo-linked volume and brightness patterns""" + + # Tempo-linked pattern types with bar lengths + TEMPO_PATTERNS = { + "static": "Static", + "1_bar_swell": "1 Bar Swell", + "2_bar_swell": "2 Bar Swell", + "4_bar_swell": "4 Bar Swell", + "8_bar_swell": "8 Bar Swell", + "1_bar_breathing": "1 Bar Breathing", + "2_bar_breathing": "2 Bar Breathing", + "4_bar_breathing": "4 Bar Breathing", + "1_bar_wave": "1 Bar Wave", + "2_bar_wave": "2 Bar Wave", + "4_bar_wave": "4 Bar Wave", + "cascade_up": "Cascade Up", + "cascade_down": "Cascade Down", + "random_sparkle": "Random Sparkle" + } + + def __init__(self, volume_engine): + super().__init__() + self.volume_engine = volume_engine + self.current_pattern = "static" + self.armed_pattern_button = None + self.pattern_buttons = {} + self.setup_ui() + self.connect_signals() + + def setup_ui(self): + """Set up the user interface""" + layout = QVBoxLayout(self) + + # Tempo-Linked Pattern Settings + pattern_group = self.create_pattern_settings() + layout.addWidget(pattern_group) + + # Global Range Settings (keep min/max volume and velocity) + global_group = self.create_global_settings() + layout.addWidget(global_group) + + layout.addStretch() + + def create_pattern_settings(self) -> QGroupBox: + """Create tempo-linked volume pattern settings""" + group = QGroupBox("Tempo-Linked Volume Patterns") + layout = QVBoxLayout(group) + + # Description + desc = QLabel("Volume changes once per note per channel, linked to arpeggiator tempo") + desc.setStyleSheet("color: #888888; font-style: italic;") + desc.setWordWrap(True) + layout.addWidget(desc) + + # Pattern buttons + pattern_widget = self.create_pattern_buttons() + layout.addWidget(pattern_widget) + + return group + + def create_pattern_buttons(self) -> QWidget: + """Create pattern selection buttons""" + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(3) + + row = 0 + col = 0 + + for pattern_key, display_name in self.TEMPO_PATTERNS.items(): + button = QPushButton(display_name) + button.setCheckable(True) + button.clicked.connect(lambda checked, p=pattern_key: self.on_pattern_button_clicked(p)) + + # Set initial state + if pattern_key == "static": + button.setChecked(True) + self.update_pattern_button_style(button, "active") + else: + self.update_pattern_button_style(button, "inactive") + + self.pattern_buttons[pattern_key] = button + layout.addWidget(button, row, col) + + col += 1 + if col >= 3: # 3 buttons per row + col = 0 + row += 1 + + return widget + + def update_pattern_button_style(self, button, state): + """Update pattern button styling based on state""" + if state == "active": + button.setStyleSheet(""" + QPushButton { + background-color: #2d5a2d; + color: white; + border: 2px solid #4a8a4a; + font-weight: bold; + min-height: 30px; + padding: 5px 10px; + } + """) + elif state == "armed": + button.setStyleSheet(""" + QPushButton { + background-color: #5a4d2d; + color: white; + border: 2px solid #8a7a4a; + font-weight: bold; + min-height: 30px; + padding: 5px 10px; + } + """) + else: # inactive + button.setStyleSheet(""" + QPushButton { + min-height: 30px; + padding: 5px 10px; + } + """) + + def create_global_settings(self) -> QGroupBox: + """Create global volume/velocity range settings""" + group = QGroupBox("Global Volume Range") + layout = QGridLayout(group) + + # Global Volume Range + layout.addWidget(QLabel("Volume Range:"), 0, 0) + vol_layout = QVBoxLayout() + + # Min Volume + min_vol_layout = QHBoxLayout() + min_vol_layout.addWidget(QLabel("Min:")) + self.min_volume_slider = QSlider(Qt.Horizontal) + self.min_volume_slider.setRange(0, 100) # 0% to 100% + self.min_volume_slider.setValue(10) # 10% for subtle lighting + self.min_volume_label = QLabel("10%") + self.min_volume_label.setFixedWidth(40) + min_vol_layout.addWidget(self.min_volume_slider) + min_vol_layout.addWidget(self.min_volume_label) + vol_layout.addLayout(min_vol_layout) + + # Max Volume + max_vol_layout = QHBoxLayout() + max_vol_layout.addWidget(QLabel("Max:")) + self.max_volume_slider = QSlider(Qt.Horizontal) + self.max_volume_slider.setRange(0, 100) # 0% to 100% + self.max_volume_slider.setValue(100) # 100% + self.max_volume_label = QLabel("100%") + self.max_volume_label.setFixedWidth(40) + max_vol_layout.addWidget(self.max_volume_slider) + max_vol_layout.addWidget(self.max_volume_label) + vol_layout.addLayout(max_vol_layout) + + layout.addLayout(vol_layout, 0, 1) + + # Global Velocity Range + layout.addWidget(QLabel("Velocity Range:"), 1, 0) + vel_layout = QVBoxLayout() + + # Min Velocity + min_vel_layout = QHBoxLayout() + min_vel_layout.addWidget(QLabel("Min:")) + self.min_velocity_slider = QSlider(Qt.Horizontal) + self.min_velocity_slider.setRange(1, 127) + self.min_velocity_slider.setValue(40) + self.min_velocity_label = QLabel("40") + self.min_velocity_label.setFixedWidth(40) + min_vel_layout.addWidget(self.min_velocity_slider) + min_vel_layout.addWidget(self.min_velocity_label) + vel_layout.addLayout(min_vel_layout) + + # Max Velocity + max_vel_layout = QHBoxLayout() + max_vel_layout.addWidget(QLabel("Max:")) + self.max_velocity_slider = QSlider(Qt.Horizontal) + self.max_velocity_slider.setRange(1, 127) + self.max_velocity_slider.setValue(127) + self.max_velocity_label = QLabel("127") + self.max_velocity_label.setFixedWidth(40) + max_vel_layout.addWidget(self.max_velocity_slider) + max_vel_layout.addWidget(self.max_velocity_label) + vel_layout.addLayout(max_vel_layout) + + layout.addLayout(vel_layout, 1, 1) + + return group + + def connect_signals(self): + """Connect GUI controls to volume engine""" + # Volume range controls + self.min_volume_slider.valueChanged.connect(self.on_min_volume_changed) + self.max_volume_slider.valueChanged.connect(self.on_max_volume_changed) + self.min_velocity_slider.valueChanged.connect(self.on_min_velocity_changed) + self.max_velocity_slider.valueChanged.connect(self.on_max_velocity_changed) + + def on_pattern_button_clicked(self, pattern): + """Handle pattern button click""" + # Note: We'll need to modify this to work with arpeggiator playing state + # For now, apply immediately + self.set_active_pattern(pattern) + + # Reset pattern position when changing patterns + self.volume_engine.reset_pattern() + + # Map our tempo patterns to volume engine patterns + if pattern == "static": + self.volume_engine.set_pattern("static") + elif "swell" in pattern: + self.volume_engine.set_pattern("swell") + # Set appropriate speed based on bar length + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) # Faster for 1 bar + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) # Normal speed + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) # Slower for 4 bars + elif "8_bar" in pattern: + self.volume_engine.set_pattern_speed(0.25) # Very slow for 8 bars + elif "breathing" in pattern: + self.volume_engine.set_pattern("breathing") + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) + elif "wave" in pattern: + self.volume_engine.set_pattern("wave") + if "1_bar" in pattern: + self.volume_engine.set_pattern_speed(2.0) + elif "2_bar" in pattern: + self.volume_engine.set_pattern_speed(1.0) + elif "4_bar" in pattern: + self.volume_engine.set_pattern_speed(0.5) + elif pattern == "cascade_up": + self.volume_engine.set_pattern("cascade") + elif pattern == "cascade_down": + self.volume_engine.set_pattern("cascade") + elif pattern == "random_sparkle": + self.volume_engine.set_pattern("random_sparkle") + + def set_active_pattern(self, pattern): + """Set active pattern button""" + # Clear current active state + if self.current_pattern in self.pattern_buttons: + self.update_pattern_button_style(self.pattern_buttons[self.current_pattern], "inactive") + + # Set new active state + self.current_pattern = pattern + self.update_pattern_button_style(self.pattern_buttons[pattern], "active") + + @pyqtSlot(int) + def on_min_volume_changed(self, value): + """Handle minimum volume change""" + # Ensure min doesn't exceed max + if value >= self.max_volume_slider.value(): + value = self.max_volume_slider.value() - 1 + self.min_volume_slider.setValue(value) + + self.min_volume_label.setText(f"{value}%") + min_vol = value / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_max_volume_changed(self, value): + """Handle maximum volume change""" + # Ensure max doesn't go below min + if value <= self.min_volume_slider.value(): + value = self.min_volume_slider.value() + 1 + self.max_volume_slider.setValue(value) + + self.max_volume_label.setText(f"{value}%") + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = value / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_min_velocity_changed(self, value): + """Handle minimum velocity change""" + # Ensure min doesn't exceed max + if value >= self.max_velocity_slider.value(): + value = self.max_velocity_slider.value() - 1 + self.min_velocity_slider.setValue(value) + + self.min_velocity_label.setText(str(value)) + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, value, self.max_velocity_slider.value()) + + @pyqtSlot(int) + def on_max_velocity_changed(self, value): + """Handle maximum velocity change""" + # Ensure max doesn't go below min + if value <= self.min_velocity_slider.value(): + value = self.min_velocity_slider.value() + 1 + self.max_velocity_slider.setValue(value) + + self.max_velocity_label.setText(str(value)) + min_vol = self.min_volume_slider.value() / 100.0 + max_vol = self.max_volume_slider.value() / 100.0 + self.volume_engine.set_global_ranges(min_vol, max_vol, self.min_velocity_slider.value(), value) \ No newline at end of file diff --git a/install_windows.py b/install_windows.py new file mode 100644 index 0000000..8b5be70 --- /dev/null +++ b/install_windows.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Windows installation script with fallback options for problematic packages. +""" + +import subprocess +import sys +import os + +def run_pip_install(packages): + """Run pip install with error handling""" + for package in packages: + try: + print(f"Installing {package}...") + result = subprocess.run([sys.executable, '-m', 'pip', 'install', package], + capture_output=True, text=True, check=True) + print(f"✓ Successfully installed {package}") + except subprocess.CalledProcessError as e: + print(f"✗ Failed to install {package}: {e}") + print("STDOUT:", e.stdout) + print("STDERR:", e.stderr) + return False + return True + +def install_dependencies(): + """Install dependencies with Windows-specific handling""" + + # Core packages that usually install fine + core_packages = [ + "PyQt5>=5.15.0", + "numpy>=1.21.0", + "pygame>=2.1.0", + "mido>=1.2.10" + ] + + print("Installing core packages...") + if not run_pip_install(core_packages): + print("Failed to install some core packages") + return False + + # Try to install python-rtmidi with different approaches + rtmidi_packages = [ + "python-rtmidi>=1.5.8", # Newer version with better Windows support + "python-rtmidi", # Latest version + "rtmidi-python>=1.1.0" # Alternative package + ] + + rtmidi_installed = False + for package in rtmidi_packages: + print(f"Trying to install {package}...") + if run_pip_install([package]): + rtmidi_installed = True + break + print(f"Failed to install {package}, trying next option...") + + if not rtmidi_installed: + print("\n⚠️ Warning: Could not install any RTMIDI package.") + print("The application will still work in simulator mode.") + print("For hardware MIDI support, you may need to:") + print("1. Install Visual Studio Build Tools") + print("2. Try: pip install --no-cache-dir python-rtmidi") + print("3. Or use the simulator mode only") + + print("\n✓ Installation completed!") + return True + +def create_fallback_rtmidi(): + """Create a fallback rtmidi module using pygame.midi""" + fallback_code = '''""" +Fallback RTMIDI implementation using pygame.midi for Windows compatibility. +""" + +import pygame.midi +import time +from typing import List, Optional, Callable + +class MidiOut: + def __init__(self, device_id): + pygame.midi.init() + self.device_id = device_id + self.midi_out = pygame.midi.Output(device_id) + + def send_message(self, message): + """Send MIDI message""" + if hasattr(message, 'bytes'): + # mido message + data = message.bytes() + else: + # Raw bytes + data = message + + if len(data) == 3: + self.midi_out.write_short(data[0], data[1], data[2]) + elif len(data) == 2: + self.midi_out.write_short(data[0], data[1]) + + def close(self): + if hasattr(self, 'midi_out'): + self.midi_out.close() + +class MidiIn: + def __init__(self, device_id, callback=None): + pygame.midi.init() + self.device_id = device_id + self.midi_in = pygame.midi.Input(device_id) + self.callback = callback + + def set_callback(self, callback): + self.callback = callback + + def poll(self): + """Poll for MIDI input (call this regularly)""" + if self.midi_in.poll() and self.callback: + midi_events = self.midi_in.read(10) + for event in midi_events: + # Convert pygame midi event to mido-like message + if self.callback: + self.callback(event) + + def close(self): + if hasattr(self, 'midi_in'): + self.midi_in.close() + +def get_output_names() -> List[str]: + """Get available MIDI output device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3]: # is_output + devices.append(info[1].decode()) + return devices + +def get_input_names() -> List[str]: + """Get available MIDI input device names""" + pygame.midi.init() + devices = [] + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2]: # is_input + devices.append(info[1].decode()) + return devices + +def open_output(name: str) -> MidiOut: + """Open MIDI output by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[3] and info[1].decode() == name: # is_output and name matches + return MidiOut(i) + raise ValueError(f"MIDI output '{name}' not found") + +def open_input(name: str, callback=None) -> MidiIn: + """Open MIDI input by name""" + pygame.midi.init() + for i in range(pygame.midi.get_count()): + info = pygame.midi.get_device_info(i) + if info[2] and info[1].decode() == name: # is_input and name matches + return MidiIn(i, callback) + raise ValueError(f"MIDI input '{name}' not found") +''' + + # Create fallback directory + fallback_dir = os.path.join(os.path.dirname(__file__), 'fallback') + os.makedirs(fallback_dir, exist_ok=True) + + # Write fallback rtmidi module + with open(os.path.join(fallback_dir, 'rtmidi_fallback.py'), 'w') as f: + f.write(fallback_code) + + with open(os.path.join(fallback_dir, '__init__.py'), 'w') as f: + f.write('# Fallback MIDI implementations') + +if __name__ == "__main__": + print("Windows MIDI Arpeggiator Installation Script") + print("=" * 50) + + # Update pip first + print("Updating pip...") + subprocess.run([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'], + capture_output=True) + + # Install dependencies + success = install_dependencies() + + # Create fallback MIDI implementation + create_fallback_rtmidi() + + if success: + print("\n🎉 Installation completed successfully!") + print("\nYou can now run the application with:") + print(" python run.py") + print("\nIf you have MIDI hardware issues, the app will work in simulator mode.") + else: + print("\n⚠️ Installation completed with warnings.") + print("Some packages failed to install, but the app should still work in simulator mode.") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e079e8b --- /dev/null +++ b/main.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +MIDI Arpeggiator - Main Application Entry Point +A modular MIDI arpeggiator with lighting control and Native Instruments Maschine integration +""" + +import sys +import os +from PyQt5.QtWidgets import QApplication +from PyQt5.QtCore import QTimer + +from gui.main_window import MainWindow +from core.output_manager import OutputManager +from core.arpeggiator_engine import ArpeggiatorEngine +from core.midi_channel_manager import MIDIChannelManager +from core.synth_router import SynthRouter +from core.volume_pattern_engine import VolumePatternEngine +from simulator.simulator_engine import SimulatorEngine +from config.configuration import Configuration +from maschine.maschine_controller import MaschineController + +class ArpeggiatorApp: + def __init__(self): + self.app = QApplication(sys.argv) + self.config = Configuration() + + # Initialize core modules + self.channel_manager = MIDIChannelManager() + self.volume_engine = VolumePatternEngine() + self.synth_router = SynthRouter(self.channel_manager) + self.simulator = SimulatorEngine() + self.output_manager = OutputManager(self.simulator) + self.arpeggiator = ArpeggiatorEngine( + self.channel_manager, + self.synth_router, + self.volume_engine, + self.output_manager + ) + + # Initialize Maschine controller + self.maschine_controller = MaschineController( + self.arpeggiator, + self.channel_manager, + self.volume_engine, + self.synth_router, + self.output_manager + ) + + # Initialize GUI + self.main_window = MainWindow( + self.arpeggiator, + self.channel_manager, + self.volume_engine, + self.output_manager, + self.simulator, + self.maschine_controller + ) + + # Volume changes are now handled directly in update_systems for active channels only + self.previous_active_channels = set() + + # Setup update timer for real-time updates + self.update_timer = QTimer() + self.update_timer.timeout.connect(self.update_systems) + self.update_timer.start(16) # ~60 FPS + + # Volume updates are now handled directly in update_systems for active channels only + + def update_systems(self): + """Update all systems that need regular refresh""" + self.arpeggiator.update() + self.simulator.update_lighting_display() + + # Update volume patterns if arpeggiator is playing + if self.arpeggiator.is_playing: + # Advance pattern position (16ms delta at 60fps) + self.volume_engine.update_pattern(0.016) + + # Only update volumes for channels that have active notes + active_channels = set([ch for ch, voices in self.channel_manager.active_voices.items() if voices]) + + if active_channels: + # Update volume patterns for active channels only + for channel in active_channels: + volume = self.volume_engine.get_channel_volume(channel, len(active_channels)) + midi_volume = int(volume * 127) + self.output_manager.send_volume_change(channel, midi_volume) + + # Handle channels that just became inactive + newly_inactive = self.previous_active_channels - active_channels + for channel in newly_inactive: + # Send one CC7 message to reset to default volume + self.output_manager.send_volume_change(channel, 100) + # Dim the visual display + if hasattr(self.main_window.arp_controls, 'simulator_display'): + self.main_window.arp_controls.simulator_display.on_midi_volume_changed(channel, 20) + + # Update previous active channels + self.previous_active_channels = active_channels.copy() + else: + # No active channels - reset all previously active ones + for channel in self.previous_active_channels: + self.output_manager.send_volume_change(channel, 100) + if hasattr(self.main_window.arp_controls, 'simulator_display'): + self.main_window.arp_controls.simulator_display.on_midi_volume_changed(channel, 20) + self.previous_active_channels = set() + + def run(self): + self.main_window.show() + return self.app.exec_() + +if __name__ == "__main__": + app = ArpeggiatorApp() + sys.exit(app.run()) \ No newline at end of file diff --git a/maschine/__init__.py b/maschine/__init__.py new file mode 100644 index 0000000..2013782 --- /dev/null +++ b/maschine/__init__.py @@ -0,0 +1 @@ +# Maschine integration module - Native Instruments Maschine controller interface \ No newline at end of file diff --git a/maschine/__pycache__/__init__.cpython-310.pyc b/maschine/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..475ee27247aa9fd7509f3e23b5a80650e40aa2b4 GIT binary patch literal 132 zcmd1j<>g`k0x^kw83920F^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D*y0cYGdS*#X zVo^asYI&ryk0@&Ee@O9{FKt1R6CFX#Y{kgg#iGv C9UI>O literal 0 HcmV?d00001 diff --git a/maschine/__pycache__/maschine_controller.cpython-310.pyc b/maschine/__pycache__/maschine_controller.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ecfd4fc0c3f60edb325e2b26342de8d9d83903f GIT binary patch literal 10018 zcmaJ{-H#jBb)QdiIQ-lXt#pDjn{HkZedpKl;WM;<%&C` z?hIEJ!EFVsg7gDzgQP$o8U)l!5g~8+1B$*BeeLsoYEhuT0(~sl!d zVd|PNh1Ick?S`#u?`y&qxyPEw1;&Bd$cg-hR(A%!azAQDt?hOgcsIIXEW4dfAia;e zqTdNz_k;G<_RF1MFX(u!qtDSN@z zR=XK@rPu8BI_*|7Zg<10?g!0KbTBq0>+Pbo9d1P)>b)oiT`zhVwzg$AY!A{ghghao zH&p(UkMHgTtynp|hxg-+PB%W(sYO={*;GEn?Br&%6|`u~A`zX9&Gv z{#e@t`-J(}X;{J%_G7JKi?>BS$O#8eA;@p$ge!`V?S>;tqKx)J;0DE@cwmT1P!!dm zxM_)+Sa@t5=#3J!9q81uX^2I!#I@z|h-Hj8!6R10Np7p8BUX9DnmEO^Rn+D|=QO@= zh%@3W*VYm``1-`iOYDO5Owi2 zyjR3a;tJj;#mnL<-mBu3Acya~D!%^MYOINC;v1+vC0-M+<9%9uQ+x~WGt3P`ydmC1 zud^tf!K)O=%TAT@_3 zQfp`-wTCv+T%149@OI(?zg`cejA?Du4Ve*WENcu$pk%@~A3 z%uNKJtL60L)3qn)>rh;Ox_SXr`aK^T6GBn30Z~1ttSIPgN>V^cz$S~7lqe}9d8(^| z@3%V5DDwTM+85Vuyme=*9p3>*fFTSace2%tGxUWNw669ZN(Xcn@I+^jxO&dW>B+xi zIwt2hEg6H;nVcPo=GAN$TC_XaALU6pZs3U?BN=Kt+KxWLPVbmcbjS&nEiT*Ra*oUS zaoOQ=AuYQ*MJ|`ddFE&UVe}sq0f+o&Y!H>Nr`W+%l3no~VYT zX4Z86=l8NcxG+b$j%mcYDz!t>SP|^CTY)O43Y->m5*sYT771VWM<+f#ER5%nA>kY8 zfGXpOu@9J%Wk8L=@MS_w^uZa(H%W#UcaNYoJE!X=ONu-NN~)Ujso6<*RJW3aUq+*( zrK)_?Z?&+)GrUl>RJH&;i7D|d^o~duD~6>zx}^`!&tb^ysAFnJz|1QpaZXmsAetz^ z^Y6B!l!Uh_ySvAiw_Z1ReaaQ%uJRIS%`RZ+sC~SK@1kEsGpHDYi*sf_XNYo1m%{$; zUBYx2P2Cnwov95ZP^Jy#Wet(ZHt>l~+ZW*++0cfjFxFta-81AH$XjdLK2Dw;V`L7k z6cMbEJwgh(zP_JiZ$?mZBgO{$>z}yUo@; zzt`_XfigQmsN8mxX+O@xxGCehok-rRpe4Uf1oIhrvUF#5Po75IJ9r{e(UNIF-b#j} zm-LcZGFQxsZvC%Q96Uc~4+(dUps+=|o&C`w#~bwrD1eQuY1k+j1?ff^*s|=9pe^Hk#(sL&?ZMi_7NoG7ZGyap4@;Q-nxuu-bBVgXP8G~(V8m{B zFGzUpB)s0-%ruwz_w8)-EZgXa{6jnuLD>=8TrjR?T3=Yk;Du+iEIS`mB|8gJvA0Fg ziJMb0zXbXu_bl_j0Fx(JIV1CsbBpEv8ZmFj+$U?xNOWLXJE`sGVhbgEO^d-ykz3P- zxdY?C*v~PubHiMALV`{nTF_uRdEqQ;`}tvhik5SXmcz6hrsWJBQ8=wl&~riW&zN3e zzc4IJ(Q=Q`a+#LPwA`UPL8k~hzhFAW{o=4VG_bDHvGFAyU*hqlAzKP0Fj!X!Xt) zU7VQzQbG~6OA|D+6)b`3^5_JwVCfj$C8qlk_1Rh3Ul=Y-?atyc?k+NS7n$bC;bOKs ztdh%vl~g5D+4d6qAX;aARCXNf_PVfU|lI`5u)WYNv_I_o-N>;&=s;>}&Z3 zHPomfTMr)>HcwmGiO0a-;TpL}gUH@j#fd@k_o;?_g)y0AMN7#6KxeT0JxU10R4HAQ zPv2^kGoOW5(kO(%gJe-hExe|rCCQeuF|@jdLqj+>t|tgh>M4f+L*fEOjU2BdQsb}k z5);}~AK(>lpZl3Au{LF#y2GEhBeS=fI9iS}Eu0E4uZS zV^jW|eg=K5FWsDr(hJ<;q9vhbeK~k>K8$CQk{Q3{)M8J7eCjZyjiUpR5&SkhB0+~> z>IXg1-qrrNbO+`qq4!V+mMjm=eM8qm{l0^va*a`jdYdB)hN@1e9$nyCn+{N*cP>SJ zn-9@Ex8>N5`98j5@8tFgk8_}XUF3m4IUq3VNBJT9RB3+)?OUjKb_)B1ysjt!1&c#B zMM0`_Mx`OQ9bpK*PT2k!t>v8xkF3s&ga^M<9@Thcb!sG``Ona5j~3YM)<%n&tp&P- z)&GRnrO|TIx^qJQlFKVuFPb|&_443UiaW_pOdDhg_T&-=9SD#(>d9s`7Bk-;u9_cr zx3)R}ov6Lr?@;7TIhn!VIOeXm9<~thqs?v1I{4hA>|Qf$w;EM^k8HC{QR^oY(5Zd& zy^R|me7Jtof9KQNw?AIrfbv5jqee9qr&MPnWdfeq(N|+_+r@Mfw4oe_0#xmXAAfrL z!}a(5Ti?5V`{obVHze5)3E;qpBqW*&I3%c2PJ8>eKf85vBY~US7^hseJ9!PNOs);- zb>w$9lRyNac(=#Klza>Q>%|0lJ|l({GmU8O2C#i8HNkKOBS|)){3A-5)R(b@S3x$_ ztjd$CHo_ATzFHPk8%C<78~Jk>uNXz6Ad5(XvOu`8Kl-+0V2RGl1yt@=6@VcRw zy6Z`#7JimmV2j>Q{cu>-k*cK*C7Y$>O2ZvvSDNND%OMYS|Bff3h@54XjFLHcX+Chx z4;ADI$KcCQA}N)+$2j$dk3G0oQC;m(CLe-4UTdX#IW`V1MvY{wzI_j)1Og<)x!n(D_v;1fj)&tP%hnl4 zbjheeI*mux7tfMSmW1&_pY89nSf(PGiDMSmNX7DGDwa@b5={^8ILSq{j+jP=)^mZ9 ziPh0@6gj zK6+cQtuL_r*pMjPIgtF^CtpT`vxrkpC=7j;Kslem?sjV35o_B0Ix^{C%7ok?Q(|KLf)!Kmmi{ulL{ z!QwD4`ZEg-Q>-PW9kVtIlkDjGdTh`g*MR|(;~M$lFgJAh8Z*pf(*lfzDPhT%VqD@N zZ-kma37_&;Y!e$NZv=MS)nJD_zfQR_#LByvxWjCi}Y{Q4C^;@16}DxrSQt()2Y3p zwbH|&N;;8h=!KwDNZFHVE(gvdJlCaViFw&%c+ze53YDY+`3v*UmiS7jnhXMGQ$@67pS|KaiaprG|14=-LI_Q&P64V zp53ZvGv#X_KEagoBvZf`AkLT}BwD$_EJM=W&wfgA#E?cM6O1H|G-eB%et$y?{aZ?Y zLdlGF{Uz#NnO?GyExA5l0O-CqvB9(3fa6#^76l6JBv?#`I@SrH_z~ZF@ZnwsGSh`t z8Z+Rd95#2#i83ECxq&w$8mYW{$#P-+uOAY?Bpmqwxl}GxKJ6)q2{=nRN){lm3IRO4 zfPUAK9JYT)9a0mBJ-bJ!kUynlhGTz)x;Kbp^hw7!1!WUnMcNe$ijAuyO;f0b~+U2I8nRaU1R#fjU_+U!#7lWvv!(cA_80|vcE3nT%|!SHeP+*gCMa|HQ_ zP~BKxuRl*e&LGzxKhAJ{!bSWr3i*=n--kPww&ZFkq#O$kAhBxRK( ze<^cB8dF`;U05(yfP|#IIldB)-v=fh4)& s)i)N$J4(MGa8;Y}Hy1BF8S2ahr^0~aWi0A+!Hn3PAGHsSX> z_exsr${mpAe)qiZcfa$!&-F(}(i(mrpZ@KIr{AY(Z?UrWXQ6ToS8xtMXn9TO!l>$b zUEPhmq3&kh#NDh~H9K$DoV-&@!dJ)k~*mi(zGn9YH9YHLo59+3x5H zM$bzO@ax&KFS83pDJ~YJmo54AQ2N!ZU(SXLUbZM3-u!%}82WN1{iO7lD#F7ur%#;D z){2crr9Q8wH;N+Im-Xr;Uw9IwVqIjee&|27ljz|HzVhAuTh5<8T1aMT00gj7J zfD>Xf;H20BI3>0MPK%oWw~6h5JH*X^JH;)4S#j&@dVUyBu&}a=6*E|AT-T^tYxFT43sagVt7vX&nchrIE!A?_3J zyKLn*2`S$1O^Ew(9r8Ap9dTGZaM{jJiX-9!7`;V2D30PjC60-QaNjB(_H1$7v&)t^ zAx>h2X|`a!Q{oZSZW528|Fr6VOneY6+e9GFplue{6W#=#@T7POPuMQPq!BUslF=fv|EmlZEy#e#RMH{sn@Hg3{@(Tk#p zC*H2c_xGF^CG_l4a}IgCd$ZI$Aw2Znq2_JuEsJ^d-pO9P=Ypu9wkN4AiVLXa6zvRA z6*biMqSnL85Ov|B^)As6FX4Umy`ouKem|scDcaN#qpr-gUQ1{3>(-S}d@uP+#H>pM0rb6hW&)~+^WXkggb1g zD#MDxhQ@2&a>WIY8?dIjxD{(<#cj#`_T;`x-C;{By4Wr5RB(@iIR*DBco*QlD`~7{ z3LA2=zqNCY#Lp(g?>au6O^h|vW1vKoQL-P(%KW@1kTEmx{F=+M6069w*xQ1D))B%bg#ny&i1N?ju98kIP-h1z@3Jg%nFB)wWLkLp6 zfbE9|l(QlO>d6xR#d?$?r?B9FyM$lM8S%kA64fX7sgx_kY|GSLZ*GcXiMFS8LBk86T`zz&hZ0 zS?`m4`T*3>O7dxLLuIvAbli<=+KP%-E^BR_wr9nJCNNM_mT1Lln{BJiSj1{=J5_<# z-$5mg5n?4A^gWNy;xkE~w|4-{(;%gxWAKct+BI!SlSW(rp~N&rZY{AkP=|Kazv#*9 zhCGFNkUc2(b;N}6sUp5m#^+@|fsfmvwI1jo*7eb5%D@*g5XVo6Z3xy_qV763L|^^@ z7MHIO{2;-{3D#}yYiLt^{s|PQHejp#Xl&jKW8HnO2-yWnb+Ypc$et@ zbbfRy`2@vUUqGRCtgTDCZ3H{vv|y{x87n54NRG9wVB%SroH;N-V{02sc9RAl#c15z zwp7``4CPFP#-iDV6SQav^EI>m+GmVQI!8Q*0DHxL*(M%w>7ss-f%f*}e!UKjoDKaRiznQD zN-49P9=TA!ReIrPTRTrmiLe@OK4(ECt(|x{JDVGop8`Fiw5SBhAo)qv;6p(tZq1fL zL;58T)?fKIV2Yp!3v`C#hSj*3@NyO{@(F?`2|mmLo9S6qiq%TX>oR#Xbn>GmPX*qQ z-H>qFa!#z4euVQrP4L46uX6=`gKVV|R4<`B*aKh~u8~m+iHgcKC(MlQKz%tVI|i8w zZ1pkY-PTPT87o<Q* zCM86X(U3VRUXan~k`XpIA)+A^LAyjWTN7)Dm~i53Y&aeHb9k`)c>+a06>leFg#1Oe z{}Mpr5lKb2UuNsC5WGmRjv23`?d!M((kc_vYJiqnORJ8w43$cqFyv>jVjmMy>vgGj z8I}JB6CNY&uA208t{E%vU)s*JwvuY6py@2IXiAu@*IksX3!1!#En7KaE%RE?cV;_9 z=IzqjZf!|uod|d~7Vz2CV?c5BV*r)kLEH%@-qdB{le5R4IC1hrbMZEO2xB4>E@?E@ zuO#B_$hooC@T(XmNtIp(rNhOT78#{dG{opi>66G7UKLrhbzAk*nEoHQf-FEkTaUsq zbQ!yR(%pR8@26l6|MRRYAMui6E12FYD1{)5oyd) z`4Tv^a8(ygF${5nff1S4ENnb=meqEY=x}SHv5wGL#WP`+oaN_XmQu62hy4zfMe4laC0<&%yp+hdswlY5t?lY=1o@JbUq zbn`}KxkgKW18b^ZG^A)&SZI2ETth#V>Qh-DDkmi-VyW~VidMe;YHrN23shAS#GR#YAvr^tb_}QSs)+T^Y9cz7N>)F?d+Zd zGY9q`oH>w-5B9pk)a#Hf5J0K(S-HaE9|0tZpRXL1U+UHV>ZtrOYxk<(TSw&|^!iqh z%0EO+eh~l(6K-o{u8C55obksTM&^ro) zbQ+A(xh!MCXzjf*W2|4W&o8G#YvWQJkwsxm4%J043*Q8MVNzKmbBbUz2UUF*`52tG zma_;ZIzk)tl8-S>K@1w_*bF9_BxU^iUtH32KFoI{b29w0-fQjL_%>_fmcBQ@RD@2+ z8&E}3w;+Dl`?LxM`f%*ANU13$vWiI*5%0sGXy~(8@F=k(p)9L)>qgAh2OSF-84ZhO zd{kYi1$`n&V)HU_1&07$HLhx};-DSw99aAn?G^nMqYMj+BNQm*P=~i-@gU_jSY8H+ z!olmMT3b&c$G(s)cIZ!~>6NR?ij_-Px9>-~Y{sR6Iws3r1(~*MRN#2Nf#=7*4>DXi zb9P?MJ@(0Cv9rUxaRo;bc2M=ahT2d?u8#tb-aPFbz(-vdwiTUY>rMf@#6pL4}c<#im_+xq~p zt$Lz6({Pe>Ia$XhQ;#W^2s-zOswvF|?xJQZHG>dAeX_nfOD)zrA1cIBHjSlcbGk~^ zCXtT9O8yNx;n9XG$XP} z;bWaJnDWqPXE9O!1^~(`Gfqy}KyGG5*O42^yEvdqQ@I5r@My0FOa}*VdfwL6U{YaH=t-^p5puo6cUeuiY4KA@W8Ze+wepAr-r z&1&GC?@$|daUf%Fm~q2bk^hWm0`dJfL>xywzP!6qN4!uG9o)Z!S(SZ2V^;pl_r_dJ z@8Vx3`TrF&ZisjP_zi}Te9kQhCIQIfLT~E+5K6Ex&Db#RJig$SE@Ug^URtdZbke?_ zm<8`JT^sws18icHsRV^26Y7aYpsbAJq%p{BuN}Gp+f)bJ!wOqw*NG`EBjl%MJhVa1 z{HlFT<3SETBS6hjqf)#J^9rq@)!MyQ8alcgDX!#%Uzz!4|CtWe0qqjyMEO_onGSL= zOef&W2ZXfUGaY#XqZIE!XPrVh(i@S!w2m9)Yv>8Nho3;9b)bVt@BD@$#KdKD;Gqqo zTR842{f;b5asVG^?4yz7z#^{ZhbbjXDvcIra3tejL@)o2TQ9F3nN`F{PqB@;ipVPY zjb$}-P;N9z_wcK8Uq>;=F`2TNQW355?LjHu-lPpX+~vnu=W>T({$f6yI*#049mm=5 z*ztuPKKN6DPrJq(^gskt1sr-+3x#D8S?Q?(S^8v5&Y0y|U&LCt(YXg_vnCfEQ$Y5cIq^>jeu3Z%1dO|*(d0{tBzczad|A;= z2B}c;*Ew{&o4R=T>9c2_n4MFfMM$2d;t;;sKwA4#ESn$(Vp`v?%B!fy$5fQsXcDd>oL;0s{c$46>1WIjk4Q&q zvW<7F?q$7Wqz!%T-}!HMRGlV&CzEn-9^E~ic5iVf-QkJx(ea7#>8;b--L0-Y?Q9#~ zX799jTBGVeWsMIjJaL5eE#l0;Uj8M)p8>$fT;^w?Qc;R}A*PSb5(yV&o?Lz^yl>{I q@VGBMNwpR81K)j(4p5XKQ6l$9%uf%3Ph&B%G&e=ecfbvvH2xQjT23_p literal 0 HcmV?d00001 diff --git a/maschine/maschine_controller.py b/maschine/maschine_controller.py new file mode 100644 index 0000000..ed6f09a --- /dev/null +++ b/maschine/maschine_controller.py @@ -0,0 +1,332 @@ +""" +Maschine Controller Module + +High-level controller that integrates Maschine hardware with the arpeggiator application. +Handles control mappings and state synchronization. +""" + +from PyQt5.QtCore import QObject, pyqtSlot +from .maschine_interface import MaschineInterface + +class MaschineController(QObject): + """ + High-level controller for Maschine integration. + Bridges Maschine hardware controls with application functionality. + """ + + def __init__(self, arpeggiator, channel_manager, volume_engine, synth_router, output_manager): + super().__init__() + + self.arpeggiator = arpeggiator + self.channel_manager = channel_manager + self.volume_engine = volume_engine + self.synth_router = synth_router + self.output_manager = output_manager + + # Maschine interface + self.maschine = MaschineInterface() + + # State tracking + self.held_notes = set() + self.shift_held = False + self.current_preset_bank = 0 + + # Pattern cycling state + self.pattern_index = 0 + self.volume_pattern_index = 0 + self.routing_pattern_index = 0 + + self.setup_connections() + + def setup_connections(self): + """Setup signal connections""" + # Maschine signals + self.maschine.pad_pressed.connect(self.on_pad_pressed) + self.maschine.pad_released.connect(self.on_pad_released) + self.maschine.encoder_changed.connect(self.on_encoder_changed) + self.maschine.button_pressed.connect(self.on_button_pressed) + + # Application signals for LED feedback + self.arpeggiator.playing_state_changed.connect(self.on_playing_state_changed) + self.arpeggiator.note_triggered.connect(self.on_note_triggered) + self.channel_manager.active_synth_count_changed.connect(self.update_channel_leds) + self.volume_engine.pattern_changed.connect(self.update_pattern_leds) + + def connect_maschine(self, input_device: str = None, output_device: str = None) -> bool: + """Connect to Maschine hardware""" + success = self.maschine.connect(input_device, output_device) + if success: + self.update_all_leds() + return success + + def disconnect_maschine(self): + """Disconnect from Maschine hardware""" + self.maschine.disconnect() + + @pyqtSlot(int, int) + def on_pad_pressed(self, pad_number: int, velocity: int): + """Handle pad press events""" + mapping = self.maschine.get_pad_mapping(pad_number) + if not mapping: + return + + if mapping['type'] == 'note_trigger': + note = mapping['note'] + self.held_notes.add(note) + + # Send note to arpeggiator + self.arpeggiator.note_on(note) + + # Visual feedback + self.maschine.send_feedback_pulse(pad_number) + + # If this is the first note and arpeggiator isn't playing, start it + if len(self.held_notes) == 1 and not self.arpeggiator.is_playing: + self.arpeggiator.start() + + @pyqtSlot(int) + def on_pad_released(self, pad_number: int): + """Handle pad release events""" + mapping = self.maschine.get_pad_mapping(pad_number) + if not mapping: + return + + if mapping['type'] == 'note_trigger': + note = mapping['note'] + if note in self.held_notes: + self.held_notes.remove(note) + + # Send note off to arpeggiator + self.arpeggiator.note_off(note) + + @pyqtSlot(int, int) + def on_encoder_changed(self, encoder_number: int, delta: int): + """Handle encoder changes""" + mapping = self.maschine.get_encoder_mapping(encoder_number) + if not mapping: + return + + mapping_type = mapping['type'] + + if mapping_type == 'tempo': + current_tempo = self.arpeggiator.tempo + new_tempo = current_tempo + (delta * mapping['step']) + new_tempo = max(mapping['min'], min(mapping['max'], new_tempo)) + self.arpeggiator.set_tempo(new_tempo) + + elif mapping_type == 'swing': + current_swing = self.arpeggiator.swing * 100 # Convert to percentage + new_swing = current_swing + (delta * mapping['step']) + new_swing = max(mapping['min'], min(mapping['max'], new_swing)) + self.arpeggiator.set_swing(new_swing / 100.0) + + elif mapping_type == 'gate': + current_gate = self.arpeggiator.gate + new_gate = current_gate + (delta * mapping['step']) + new_gate = max(mapping['min'], min(mapping['max'], new_gate)) + self.arpeggiator.set_gate(new_gate) + + elif mapping_type == 'root_note': + current_note = self.arpeggiator.root_note + new_note = current_note + delta + new_note = max(mapping['min'], min(mapping['max'], new_note)) + self.arpeggiator.set_root_note(new_note) + + elif mapping_type == 'pattern_select': + if delta != 0: + patterns = mapping['values'] + self.pattern_index = (self.pattern_index + (1 if delta > 0 else -1)) % len(patterns) + self.arpeggiator.set_pattern_type(patterns[self.pattern_index]) + + elif mapping_type == 'scale_select': + if delta != 0: + scales = mapping['values'] + current_index = scales.index(self.arpeggiator.scale) if self.arpeggiator.scale in scales else 0 + new_index = (current_index + (1 if delta > 0 else -1)) % len(scales) + self.arpeggiator.set_scale(scales[new_index]) + + elif mapping_type == 'volume_pattern': + if delta != 0: + patterns = mapping['values'] + self.volume_pattern_index = (self.volume_pattern_index + (1 if delta > 0 else -1)) % len(patterns) + self.volume_engine.set_pattern(patterns[self.volume_pattern_index]) + + elif mapping_type == 'pattern_speed': + current_speed = self.volume_engine.pattern_speed + new_speed = current_speed + (delta * mapping['step']) + new_speed = max(mapping['min'], min(mapping['max'], new_speed)) + self.volume_engine.set_pattern_speed(new_speed) + + @pyqtSlot(int) + def on_button_pressed(self, button_number: int): + """Handle button press events""" + # Find button name from number + button_name = None + for name, num in self.maschine.MASCHINE_BUTTONS.items(): + if num == button_number: + button_name = name + break + + if not button_name: + return + + mapping = self.maschine.button_mappings.get(button_name) + if not mapping: + return + + mapping_type = mapping['type'] + + if mapping_type == 'preset': + # Load preset (would need preset system integration) + preset_number = mapping['preset_number'] + # self.load_preset(preset_number) + + elif mapping_type == 'output_mode_toggle': + current_mode = self.output_manager.current_mode + new_mode = "hardware" if current_mode == "simulator" else "simulator" + self.output_manager.set_mode(new_mode) + + elif mapping_type == 'routing_pattern_cycle': + patterns = self.synth_router.ROUTING_PATTERNS + self.routing_pattern_index = (self.routing_pattern_index + 1) % len(patterns) + self.synth_router.set_routing_pattern(patterns[self.routing_pattern_index]) + + elif mapping_type == 'volume_pattern_cycle': + patterns = self.volume_engine.PATTERN_TYPES + self.volume_pattern_index = (self.volume_pattern_index + 1) % len(patterns) + self.volume_engine.set_pattern(patterns[self.volume_pattern_index]) + + elif mapping_type == 'panic': + self.output_manager.send_panic() + self.all_notes_off() + + elif mapping_type == 'restart_pattern': + self.arpeggiator.stop() + if self.held_notes: + self.arpeggiator.start() + + @pyqtSlot(bool) + def on_playing_state_changed(self, is_playing: bool): + """Update LEDs based on playing state""" + if is_playing: + # Pulse active pads when playing + self.pulse_active_pads() + else: + # Update static display + self.update_note_leds() + + @pyqtSlot(int, int, int, float) + def on_note_triggered(self, channel: int, note: int, velocity: int, duration: float): + """Provide visual feedback when notes are triggered""" + # Flash corresponding pad if the note maps to one + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger' and mapping.get('note') == note: + brightness = velocity / 127.0 + # Calculate color based on channel + hue = (channel - 1) / 16.0 + self.maschine.set_pad_color_brightness(pad, brightness, hue) + break + + def update_all_leds(self): + """Update all LED displays""" + self.update_note_leds() + self.update_channel_leds() + self.update_pattern_leds() + + def update_note_leds(self): + """Update pad LEDs based on held notes""" + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger': + note = mapping.get('note') + if note in self.held_notes: + self.maschine.set_pad_led(pad, 100, "green") + else: + self.maschine.set_pad_led(pad, 20, "white") + + def update_channel_leds(self): + """Update LEDs based on active channels""" + active_count = self.channel_manager.active_synth_count + + # Use first N pads to indicate active channels + for i in range(1, 17): + if i <= active_count: + # Get channel status + voices = self.channel_manager.get_voice_count(i) + if voices > 0: + brightness = min(100 + voices * 20, 127) # Brighter with more voices + self.maschine.set_pad_led(i, brightness, "blue") + else: + self.maschine.set_pad_led(i, 30, "blue") + else: + self.maschine.set_pad_led(i, 0) + + def update_pattern_leds(self): + """Update LEDs based on current patterns""" + # This could use different pad modes or encoder LEDs + pass + + def pulse_active_pads(self): + """Pulse pads that correspond to held notes""" + for pad, mapping in self.maschine.pad_mappings.items(): + if mapping.get('type') == 'note_trigger': + note = mapping.get('note') + if note in self.held_notes: + self.maschine.flash_pad(pad, "yellow", 100) + + def all_notes_off(self): + """Stop all notes and clear held notes""" + self.held_notes.clear() + for note in range(128): + self.arpeggiator.note_off(note) + self.update_note_leds() + + def set_pad_mode(self, mode: str): + """Set pad display mode (notes, channels, patterns, etc.)""" + if mode == "notes": + self.setup_note_mappings() + elif mode == "channels": + self.setup_channel_mappings() + elif mode == "patterns": + self.setup_pattern_mappings() + + self.update_all_leds() + + def setup_note_mappings(self): + """Setup pads for note triggering""" + for i in range(1, 17): + self.maschine.set_pad_mapping(i, { + 'type': 'note_trigger', + 'note': 48 + i, # C3 and up + 'function': None + }) + + def setup_channel_mappings(self): + """Setup pads for channel control""" + for i in range(1, 17): + self.maschine.set_pad_mapping(i, { + 'type': 'channel_select', + 'channel': i, + 'function': None + }) + + def setup_pattern_mappings(self): + """Setup pads for pattern selection""" + patterns = self.arpeggiator.PATTERN_TYPES + for i, pattern in enumerate(patterns[:16], 1): + self.maschine.set_pad_mapping(i, { + 'type': 'pattern_select', + 'pattern': pattern, + 'function': None + }) + + def is_connected(self) -> bool: + """Check if Maschine is connected""" + return self.maschine.is_connected() + + def get_status(self) -> dict: + """Get comprehensive status""" + return { + 'connected': self.is_connected(), + 'held_notes': list(self.held_notes), + 'maschine_status': self.maschine.get_status() + } \ No newline at end of file diff --git a/maschine/maschine_interface.py b/maschine/maschine_interface.py new file mode 100644 index 0000000..60740b6 --- /dev/null +++ b/maschine/maschine_interface.py @@ -0,0 +1,379 @@ +""" +Maschine Interface Module + +Native Instruments Maschine integration for hardware control of the arpeggiator. +Provides MIDI mapping for pads, encoders, and buttons. +""" + +import mido +import threading +import time +from typing import Dict, List, Optional, Callable, Any +from PyQt5.QtCore import QObject, pyqtSignal + +class MaschineInterface(QObject): + """ + Interface for Native Instruments Maschine controllers. + Handles MIDI input/output for pads, encoders, buttons, and LED feedback. + """ + + # Signals + pad_pressed = pyqtSignal(int, int) # pad_number, velocity + pad_released = pyqtSignal(int) # pad_number + encoder_changed = pyqtSignal(int, int) # encoder_number, delta + button_pressed = pyqtSignal(int) # button_number + button_released = pyqtSignal(int) # button_number + + # Maschine MIDI mapping (for Maschine MK3) + MASCHINE_PADS = { + # Pad numbers to MIDI notes + 1: 36, 2: 37, 3: 38, 4: 39, + 5: 40, 6: 41, 7: 42, 8: 43, + 9: 44, 10: 45, 11: 46, 12: 47, + 13: 48, 14: 49, 15: 50, 16: 51 + } + + MASCHINE_ENCODERS = { + # Encoder numbers to MIDI CC + 1: 1, # Encoder 1 (Tempo/Master) + 2: 2, # Encoder 2 (Swing) + 3: 3, # Encoder 3 (Pattern) + 4: 4, # Encoder 4 (Scale) + 5: 5, # Encoder 5 (Volume Pattern) + 6: 6, # Encoder 6 (Pattern Speed) + 7: 7, # Encoder 7 (Gate) + 8: 8 # Encoder 8 (Root Note) + } + + MASCHINE_BUTTONS = { + # Button numbers to MIDI notes/CC + "scene1": 52, "scene2": 53, "scene3": 54, "scene4": 55, + "group": 56, "browse": 57, "sampling": 58, "all": 59, + "auto": 60, "lock": 61, "note_repeat": 62, "restart": 63 + } + + def __init__(self): + super().__init__() + + # MIDI connections + self.midi_input = None + self.midi_output = None + self.input_device_name = "" + self.output_device_name = "" + + # State tracking + self.pad_states = {} # pad -> velocity + self.encoder_values = {} # encoder -> current_value + self.button_states = {} # button -> pressed + + # Control mappings (what each control does) + self.pad_mappings = {} # pad -> function + self.encoder_mappings = {} # encoder -> function + self.button_mappings = {} # button -> function + + # LED states for feedback + self.pad_leds = {i: 0 for i in range(1, 17)} # pad -> brightness (0-127) + + # Thread for MIDI input processing + self.input_thread = None + self.running = False + + # Setup default mappings + self.setup_default_mappings() + + def setup_default_mappings(self): + """Setup default control mappings""" + + # Pad mappings - trigger notes for arpeggiator + for i in range(1, 17): + self.pad_mappings[i] = { + 'type': 'note_trigger', + 'note': 48 + i, # C3 and up + 'function': None + } + + # Encoder mappings + self.encoder_mappings = { + 1: {'type': 'tempo', 'min': 40, 'max': 200, 'step': 1}, + 2: {'type': 'swing', 'min': -100, 'max': 100, 'step': 5}, + 3: {'type': 'pattern_select', 'values': ['up', 'down', 'up_down', 'random']}, + 4: {'type': 'scale_select', 'values': ['major', 'minor', 'dorian', 'pentatonic_major']}, + 5: {'type': 'volume_pattern', 'values': ['static', 'swell', 'breathing', 'wave']}, + 6: {'type': 'pattern_speed', 'min': 0.1, 'max': 5.0, 'step': 0.1}, + 7: {'type': 'gate', 'min': 0.1, 'max': 2.0, 'step': 0.05}, + 8: {'type': 'root_note', 'min': 0, 'max': 127, 'step': 1} + } + + # Button mappings + self.button_mappings = { + 'scene1': {'type': 'preset', 'preset_number': 1}, + 'scene2': {'type': 'preset', 'preset_number': 2}, + 'scene3': {'type': 'preset', 'preset_number': 3}, + 'scene4': {'type': 'preset', 'preset_number': 4}, + 'group': {'type': 'output_mode_toggle'}, + 'browse': {'type': 'routing_pattern_cycle'}, + 'sampling': {'type': 'volume_pattern_cycle'}, + 'all': {'type': 'panic'}, + 'note_repeat': {'type': 'hold_toggle'}, + 'restart': {'type': 'restart_pattern'} + } + + def find_maschine_devices(self) -> tuple: + """Find Maschine MIDI devices""" + inputs = mido.get_input_names() + outputs = mido.get_output_names() + + maschine_inputs = [name for name in inputs if 'maschine' in name.lower()] + maschine_outputs = [name for name in outputs if 'maschine' in name.lower()] + + return maschine_inputs, maschine_outputs + + def connect(self, input_name: str = None, output_name: str = None) -> bool: + """Connect to Maschine devices""" + try: + # Auto-detect if not specified + if not input_name or not output_name: + inputs, outputs = self.find_maschine_devices() + if not inputs or not outputs: + return False + input_name = input_name or inputs[0] + output_name = output_name or outputs[0] + + # Close existing connections + self.disconnect() + + # Open MIDI connections + self.midi_input = mido.open_input(input_name, callback=self._process_midi_message) + self.midi_output = mido.open_output(output_name) + + self.input_device_name = input_name + self.output_device_name = output_name + self.running = True + + # Initialize Maschine state + self.initialize_maschine() + + return True + + except Exception as e: + print(f"Error connecting to Maschine: {e}") + return False + + def disconnect(self): + """Disconnect from Maschine devices""" + self.running = False + + if self.midi_input: + try: + self.midi_input.close() + except: + pass + self.midi_input = None + + if self.midi_output: + try: + # Turn off all LEDs + self.all_leds_off() + self.midi_output.close() + except: + pass + self.midi_output = None + + def initialize_maschine(self): + """Initialize Maschine controller state""" + if not self.midi_output: + return + + # Turn off all pad LEDs + self.all_leds_off() + + # Set initial pad colors/brightness based on current state + self.update_pad_leds() + + def _process_midi_message(self, message): + """Process incoming MIDI messages from Maschine""" + if not self.running: + return + + try: + if message.type == 'note_on': + self._handle_pad_press(message.note, message.velocity) + elif message.type == 'note_off': + self._handle_pad_release(message.note) + elif message.type == 'control_change': + self._handle_encoder_change(message.control, message.value) + + except Exception as e: + print(f"Error processing MIDI message: {e}") + + def _handle_pad_press(self, midi_note: int, velocity: int): + """Handle pad press""" + # Find pad number from MIDI note + pad_number = None + for pad, note in self.MASCHINE_PADS.items(): + if note == midi_note: + pad_number = pad + break + + if pad_number: + self.pad_states[pad_number] = velocity + self.pad_pressed.emit(pad_number, velocity) + + # Update LED + self.set_pad_led(pad_number, velocity) + + def _handle_pad_release(self, midi_note: int): + """Handle pad release""" + # Find pad number from MIDI note + pad_number = None + for pad, note in self.MASCHINE_PADS.items(): + if note == midi_note: + pad_number = pad + break + + if pad_number: + self.pad_states[pad_number] = 0 + self.pad_released.emit(pad_number) + + # Update LED + self.set_pad_led(pad_number, 0) + + def _handle_encoder_change(self, cc_number: int, value: int): + """Handle encoder change""" + # Find encoder number from CC + encoder_number = None + for enc, cc in self.MASCHINE_ENCODERS.items(): + if cc == cc_number: + encoder_number = enc + break + + if encoder_number: + # Calculate delta (encoders send relative values) + old_value = self.encoder_values.get(encoder_number, 64) + delta = value - old_value + + # Handle encoder wrap-around + if delta > 64: + delta = delta - 128 + elif delta < -64: + delta = delta + 128 + + self.encoder_values[encoder_number] = value + self.encoder_changed.emit(encoder_number, delta) + + def set_pad_led(self, pad_number: int, brightness: int, color: str = "white"): + """Set pad LED brightness and color""" + if not self.midi_output or pad_number not in self.MASCHINE_PADS: + return + + try: + # Maschine uses velocity for LED brightness in note messages + # Different channels can represent different colors + channel = 0 # Default channel for white + if color == "red": + channel = 1 + elif color == "green": + channel = 2 + elif color == "blue": + channel = 3 + elif color == "yellow": + channel = 4 + + midi_note = self.MASCHINE_PADS[pad_number] + msg = mido.Message('note_on', channel=channel, note=midi_note, velocity=brightness) + self.midi_output.send(msg) + + self.pad_leds[pad_number] = brightness + + except Exception as e: + print(f"Error setting pad LED: {e}") + + def set_pad_color_brightness(self, pad_number: int, brightness: float, color_hue: float = 0.0): + """Set pad LED with color based on hue (0.0-1.0) and brightness (0.0-1.0)""" + if brightness < 0.1: + self.set_pad_led(pad_number, 0) + return + + # Convert brightness to MIDI velocity + velocity = int(brightness * 127) + + # Simple color mapping based on hue + if 0.0 <= color_hue < 0.2: # Red + color = "red" + elif 0.2 <= color_hue < 0.4: # Yellow + color = "yellow" + elif 0.4 <= color_hue < 0.6: # Green + color = "green" + elif 0.6 <= color_hue < 0.8: # Blue + color = "blue" + else: # White + color = "white" + + self.set_pad_led(pad_number, velocity, color) + + def all_leds_off(self): + """Turn off all pad LEDs""" + for pad in range(1, 17): + self.set_pad_led(pad, 0) + + def update_pad_leds(self): + """Update all pad LEDs based on current state""" + # This would be called by the main application to update LED states + # based on current arpeggiator state, active channels, etc. + pass + + def flash_pad(self, pad_number: int, color: str = "white", duration_ms: int = 100): + """Flash a pad LED briefly""" + self.set_pad_led(pad_number, 127, color) + + # Schedule turning off (would need QTimer in real implementation) + def turn_off(): + time.sleep(duration_ms / 1000.0) + self.set_pad_led(pad_number, 0) + + threading.Thread(target=turn_off, daemon=True).start() + + def set_encoder_mapping(self, encoder_number: int, mapping: Dict[str, Any]): + """Set custom mapping for an encoder""" + if 1 <= encoder_number <= 8: + self.encoder_mappings[encoder_number] = mapping + + def set_pad_mapping(self, pad_number: int, mapping: Dict[str, Any]): + """Set custom mapping for a pad""" + if 1 <= pad_number <= 16: + self.pad_mappings[pad_number] = mapping + + def get_encoder_mapping(self, encoder_number: int) -> Optional[Dict[str, Any]]: + """Get current mapping for an encoder""" + return self.encoder_mappings.get(encoder_number) + + def get_pad_mapping(self, pad_number: int) -> Optional[Dict[str, Any]]: + """Get current mapping for a pad""" + return self.pad_mappings.get(pad_number) + + def send_feedback_pulse(self, pad_number: int): + """Send visual feedback for successful action""" + self.flash_pad(pad_number, "green", 150) + + def send_error_feedback(self, pad_number: int): + """Send visual feedback for error/invalid action""" + self.flash_pad(pad_number, "red", 300) + + def is_connected(self) -> bool: + """Check if Maschine is connected""" + return self.midi_input is not None and self.midi_output is not None + + def get_status(self) -> Dict[str, Any]: + """Get current Maschine interface status""" + return { + 'connected': self.is_connected(), + 'input_device': self.input_device_name, + 'output_device': self.output_device_name, + 'active_pads': len([p for p, v in self.pad_states.items() if v > 0]), + 'pad_states': self.pad_states.copy(), + 'encoder_values': self.encoder_values.copy() + } + + def __del__(self): + """Cleanup on destruction""" + self.disconnect() \ No newline at end of file diff --git a/presets/butt.json b/presets/butt.json new file mode 100644 index 0000000..653a540 --- /dev/null +++ b/presets/butt.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "timestamp": "2025-09-07T23:11:06.343054", + "arpeggiator": { + "root_note": 50, + "scale": "pentatonic_major", + "pattern_type": "up", + "octave_range": 1, + "note_speed": "1/2", + "gate": 1.0, + "swing": 0.0, + "velocity": 80, + "tempo": 120.0 + }, + "channels": { + "active_synth_count": 3, + "channel_instruments": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "13": 0, + "14": 0, + "15": 0, + "16": 0 + } + }, + "volume_patterns": { + "current_pattern": "swell", + "pattern_speed": 2.0, + "pattern_intensity": 1.0, + "global_volume_range": [ + 0.2, + 1.0 + ], + "global_velocity_range": [ + 40, + 127 + ], + "channel_volume_ranges": {}, + "velocity_ranges": {} + } +} \ No newline at end of file diff --git a/presets/butt2.json b/presets/butt2.json new file mode 100644 index 0000000..63aeb69 --- /dev/null +++ b/presets/butt2.json @@ -0,0 +1,51 @@ +{ + "version": "1.0", + "timestamp": "2025-09-08T11:17:34.584516", + "arpeggiator": { + "root_note": 60, + "scale": "major", + "pattern_type": "up", + "octave_range": 1, + "note_speed": "1/1", + "gate": 0.78, + "swing": 0.0, + "velocity": 100, + "tempo": 66.0 + }, + "channels": { + "active_synth_count": 8, + "channel_instruments": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0, + "13": 0, + "14": 0, + "15": 0, + "16": 0 + } + }, + "volume_patterns": { + "current_pattern": "random_sparkle", + "pattern_speed": 0.5, + "pattern_intensity": 1.0, + "global_volume_range": [ + 0.0, + 1.0 + ], + "global_velocity_range": [ + 126, + 127 + ], + "channel_volume_ranges": {}, + "velocity_ranges": {} + } +} \ No newline at end of file diff --git a/requirements-windows.txt b/requirements-windows.txt new file mode 100644 index 0000000..8e3dcac --- /dev/null +++ b/requirements-windows.txt @@ -0,0 +1,9 @@ +# Windows-specific requirements with pre-compiled packages +PyQt5>=5.15.0 +mido>=1.2.10 +numpy>=1.21.0 +pygame>=2.1.0 + +# Alternative MIDI libraries for Windows (try these if python-rtmidi fails) +# rtmidi-python>=1.1.0 +# pygame-midi # Built into pygame on Windows \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f60bcf0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt5>=5.15.0 +python-rtmidi>=1.5.0 +mido>=1.2.10 +numpy>=1.21.0 +pygame>=2.1.0 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..ab20d9c --- /dev/null +++ b/run.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +""" +Simple startup script for the MIDI Arpeggiator application. +""" + +import sys +import os + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import and run the main application +from main import ArpeggiatorApp + +if __name__ == "__main__": + try: + app = ArpeggiatorApp() + sys.exit(app.run()) + except KeyboardInterrupt: + print("\nApplication interrupted by user") + sys.exit(0) + except Exception as e: + print(f"Fatal error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/simple_audio_test.py b/simple_audio_test.py new file mode 100644 index 0000000..1373cf8 --- /dev/null +++ b/simple_audio_test.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Simple audio and MIDI test without Unicode issues +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_basic_audio(): + """Test basic pygame audio""" + print("=== Testing Basic Audio ===") + + try: + import pygame + import numpy as np + + pygame.mixer.pre_init(frequency=22050, size=-16, channels=2, buffer=512) + pygame.mixer.init() + print("PASS: Pygame mixer initialized") + + # Generate test tone + sample_rate = 22050 + duration = 1.0 + frequency = 440 + + samples = np.sin(2 * np.pi * frequency * np.linspace(0, duration, int(sample_rate * duration))) + samples = (samples * 0.3 * 32767).astype(np.int16) + stereo_samples = np.column_stack((samples, samples)) + + sound = pygame.sndarray.make_sound(stereo_samples) + print("Playing test tone...") + sound.play() + time.sleep(1.2) + print("PASS: Basic audio working") + return True + + except Exception as e: + print(f"FAIL: Basic audio error: {e}") + return False + +def test_simulator(): + """Test simulator engine""" + print("\n=== Testing Simulator ===") + + try: + from simulator.simulator_engine import SimulatorEngine + + simulator = SimulatorEngine() + print(f"Simulator audio enabled: {simulator.audio_enabled}") + print(f"Audio initialized: {simulator.audio_initialized_flag}") + + print("Testing simulator note...") + simulator.play_note(1, 60, 80) + time.sleep(1) + simulator.stop_note(1, 60) + print("PASS: Simulator test completed") + return True + + except Exception as e: + print(f"FAIL: Simulator error: {e}") + return False + +def test_midi(): + """Test MIDI devices""" + print("\n=== Testing MIDI ===") + + try: + import mido + + outputs = mido.get_output_names() + print(f"Found {len(outputs)} MIDI outputs:") + for i, output in enumerate(outputs): + print(f" {i+1}: {output}") + + if outputs: + print(f"Testing with: {outputs[0]}") + midi_out = mido.open_output(outputs[0]) + + # Send test note + msg_on = mido.Message('note_on', channel=0, note=60, velocity=80) + msg_off = mido.Message('note_off', channel=0, note=60, velocity=0) + + midi_out.send(msg_on) + print("Sent note ON") + time.sleep(0.5) + midi_out.send(msg_off) + print("Sent note OFF") + + midi_out.close() + print("PASS: MIDI test completed") + return True + else: + print("FAIL: No MIDI outputs found") + return False + + except Exception as e: + print(f"FAIL: MIDI error: {e}") + return False + +def test_arpeggiator_notes(): + """Test if arpeggiator actually triggers notes""" + print("\n=== Testing Arpeggiator Note Triggering ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create system + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + print("Components created") + + # Set to simulator mode + output_manager.set_mode("simulator") + print(f"Output mode: {output_manager.current_mode}") + + # Configure + arpeggiator.set_root_note(60) + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(240) # Fast tempo for quick test + + # Add notes + print("Adding notes C and E...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + print(f"Held notes: {len(arpeggiator.held_notes)}") + print(f"Pattern length: {arpeggiator.pattern_length}") + + # Start arpeggiator + print("Starting arpeggiator...") + started = arpeggiator.start() + print(f"Started: {started}") + + if started: + print("Running for 3 seconds...") + time.sleep(3) + arpeggiator.stop() + print("Stopped") + return True + else: + print("FAIL: Could not start arpeggiator") + return False + + except Exception as e: + print(f"FAIL: Arpeggiator error: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + print("Simple Audio/MIDI Diagnostic") + print("=" * 40) + + # Test basic audio first + audio_ok = test_basic_audio() + + # Test simulator + sim_ok = test_simulator() + + # Test MIDI + midi_ok = test_midi() + + # Test full arpeggiator + arp_ok = test_arpeggiator_notes() + + print("\n" + "=" * 40) + print("RESULTS:") + print(f"Basic Audio: {'PASS' if audio_ok else 'FAIL'}") + print(f"Simulator: {'PASS' if sim_ok else 'FAIL'}") + print(f"MIDI: {'PASS' if midi_ok else 'FAIL'}") + print(f"Arpeggiator: {'PASS' if arp_ok else 'FAIL'}") + + if not audio_ok: + print("\nAudio issues: Check Windows audio settings") + if not midi_ok: + print("\nMIDI issues: Install virtual MIDI cable (loopMIDI)") + if not arp_ok: + print("\nArpeggiator issues: Check component integration") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/simulator/__init__.py b/simulator/__init__.py new file mode 100644 index 0000000..f1697ea --- /dev/null +++ b/simulator/__init__.py @@ -0,0 +1 @@ +# Simulator module - Internal audio synthesis and visual lighting simulation \ No newline at end of file diff --git a/simulator/__pycache__/__init__.cpython-310.pyc b/simulator/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e6e14427230a901138fab4c9d7063dd811332f9 GIT binary patch literal 133 zcmd1j<>g`kf|a8CGJJsaV-N=!FakLaKwQiMBvKfH88jLFRx%WUgb~Cq4QH#E^vsf& z#G-_IA955?BJ~J<~BtBlRpz;=nO>TZlX-=vg$b@1hAi=@_03MYc A1ONa4 literal 0 HcmV?d00001 diff --git a/simulator/__pycache__/simulator_engine.cpython-310.pyc b/simulator/__pycache__/simulator_engine.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c15074a89e3368c43dbebacf31aaa225d3a3a52b GIT binary patch literal 11280 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 IHb<@h1@mQd+yDRo literal 0 HcmV?d00001 diff --git a/simulator/simulator_engine.py b/simulator/simulator_engine.py new file mode 100644 index 0000000..89613eb --- /dev/null +++ b/simulator/simulator_engine.py @@ -0,0 +1,390 @@ +""" +Simulator Engine Module + +Internal audio synthesis and visual lighting simulation. +Provides audio feedback and visual representation of the lighting installation. +""" + +import pygame +import numpy as np +import threading +import time +import math +from typing import Dict, List, Optional, Tuple +from PyQt5.QtCore import QObject, pyqtSignal + +class SimulatorEngine(QObject): + """ + Internal simulator for audio synthesis and lighting visualization. + Provides real-time audio feedback and visual representation. + """ + + # Signals + audio_initialized = pyqtSignal(bool) + note_played = pyqtSignal(int, int, int) # channel, note, velocity + note_stopped = pyqtSignal(int, int) # channel, note + program_changed = pyqtSignal(int, int) # channel, program + volume_changed = pyqtSignal(int, int) # channel, volume + lighting_updated = pyqtSignal(dict) # {channel: brightness} + + # Waveform types + WAVEFORMS = ["sine", "square", "sawtooth", "triangle", "noise"] + + # GM Instrument to waveform mapping (simplified) + GM_WAVEFORM_MAP = { + # Pianos (0-7) + 0: "sine", 1: "sine", 2: "sine", 3: "square", + 4: "sine", 5: "sine", 6: "square", 7: "square", + + # Chromatic Percussion (8-15) + 8: "sine", 9: "sine", 10: "sine", 11: "sine", + 12: "sine", 13: "sine", 14: "sine", 15: "triangle", + + # Organs (16-23) + 16: "square", 17: "square", 18: "square", 19: "square", + 20: "square", 21: "square", 22: "triangle", 23: "square", + + # Guitars (24-31) + 24: "sawtooth", 25: "sawtooth", 26: "sawtooth", 27: "sawtooth", + 28: "square", 29: "sawtooth", 30: "sawtooth", 31: "sine", + + # Bass (32-39) + 32: "sawtooth", 33: "sawtooth", 34: "sawtooth", 35: "sawtooth", + 36: "sawtooth", 37: "sawtooth", 38: "sawtooth", 39: "sawtooth", + + # Strings (40-47) + 40: "sawtooth", 41: "sawtooth", 42: "sawtooth", 43: "sawtooth", + 44: "sawtooth", 45: "sawtooth", 46: "sine", 47: "sine", + + # Ensemble (48-55) + 48: "sawtooth", 49: "sawtooth", 50: "sawtooth", 51: "sawtooth", + 52: "sine", 53: "sine", 54: "sawtooth", 55: "sine", + + # Brass (56-63) + 56: "sawtooth", 57: "sawtooth", 58: "sawtooth", 59: "sawtooth", + 60: "sawtooth", 61: "sawtooth", 62: "sawtooth", 63: "sawtooth", + + # Reed (64-71) + 64: "sawtooth", 65: "sawtooth", 66: "sawtooth", 67: "sawtooth", + 68: "sine", 69: "sine", 70: "sine", 71: "sine", + + # Pipe (72-79) + 72: "sine", 73: "sine", 74: "sine", 75: "sine", + 76: "sine", 77: "sine", 78: "sine", 79: "sine", + + # Synth Lead (80-87) + 80: "square", 81: "sawtooth", 82: "square", 83: "square", + 84: "sawtooth", 85: "square", 86: "sawtooth", 87: "sawtooth", + + # Synth Pad (88-95) + 88: "sawtooth", 89: "sawtooth", 90: "sawtooth", 91: "sine", + 92: "sawtooth", 93: "sawtooth", 94: "sine", 95: "sawtooth", + + # Synth Effects (96-103) + 96: "noise", 97: "sawtooth", 98: "sine", 99: "sawtooth", + 100: "sawtooth", 101: "noise", 102: "sine", 103: "sawtooth", + + # Ethnic (104-111) + 104: "sawtooth", 105: "sawtooth", 106: "sawtooth", 107: "sine", + 108: "sine", 109: "square", 110: "sawtooth", 111: "sine", + + # Percussive (112-119) + 112: "noise", 113: "noise", 114: "sawtooth", 115: "noise", + 116: "noise", 117: "noise", 118: "noise", 119: "noise", + + # Sound Effects (120-127) + 120: "noise", 121: "noise", 122: "noise", 123: "noise", + 124: "noise", 125: "noise", 126: "noise", 127: "noise" + } + + def __init__(self): + super().__init__() + + # Audio settings + self.sample_rate = 22050 + self.buffer_size = 512 + self.audio_enabled = True + self.master_volume = 0.8 + self.stereo_mode = False # Will be set during audio initialization + + # Channel settings + self.channel_volumes = {i: 100 for i in range(1, 17)} # MIDI volume (0-127) + self.channel_programs = {i: 0 for i in range(1, 17)} # GM program numbers + + # Active voices {(channel, note): voice_data} + self.active_voices: Dict[Tuple[int, int], Dict] = {} + + # Lighting state {channel: brightness (0.0-1.0)} + self.lighting_state = {i: 0.0 for i in range(1, 17)} + + # Audio system + self.audio_initialized_flag = False + self.audio_thread = None + self.audio_running = False + + # Initialize audio + self.initialize_audio() + + def initialize_audio(self): + """Initialize pygame audio system""" + try: + # Try stereo first (most common) + try: + pygame.mixer.pre_init( + frequency=self.sample_rate, + size=-16, + channels=2, # Stereo + buffer=self.buffer_size + ) + pygame.mixer.init() + self.stereo_mode = True + except: + # Fall back to mono if stereo fails + pygame.mixer.pre_init( + frequency=self.sample_rate, + size=-16, + channels=1, # Mono + buffer=self.buffer_size + ) + pygame.mixer.init() + self.stereo_mode = False + + self.audio_initialized_flag = True + self.audio_initialized.emit(True) + print(f"Audio initialized: {self.sample_rate}Hz, {'Stereo' if self.stereo_mode else 'Mono'}") + + except Exception as e: + print(f"Audio initialization failed: {e}") + self.audio_initialized_flag = False + self.stereo_mode = False + self.audio_initialized.emit(False) + + def set_audio_enabled(self, enabled: bool): + """Enable or disable audio output""" + self.audio_enabled = enabled + + def set_master_volume(self, volume: float): + """Set master volume (0.0 to 1.0)""" + self.master_volume = max(0.0, min(1.0, volume)) + + def play_note(self, channel: int, note: int, velocity: int): + """Play a note on the specified channel""" + if not (1 <= channel <= 16 and 0 <= note <= 127 and 0 <= velocity <= 127): + return + + # Stop any existing note on this channel/note combination + self.stop_note(channel, note) + + # Get program for channel + program = self.channel_programs.get(channel, 0) + waveform = self.GM_WAVEFORM_MAP.get(program, "sine") + + # Calculate frequency + frequency = self.midi_note_to_frequency(note) + + # Calculate volume + channel_volume = self.channel_volumes.get(channel, 100) / 127.0 + velocity_volume = velocity / 127.0 + final_volume = channel_volume * velocity_volume * self.master_volume + + # Create voice data + voice_data = { + 'frequency': frequency, + 'waveform': waveform, + 'volume': final_volume, + 'phase': 0.0, + 'start_time': time.time(), + 'velocity': velocity + } + + # Store active voice + self.active_voices[(channel, note)] = voice_data + + # Update lighting + self.update_lighting(channel, velocity, channel_volume) + + # Play audio if enabled + if self.audio_enabled and self.audio_initialized_flag: + self.play_audio_note(frequency, waveform, final_volume) + + # Emit signal + self.note_played.emit(channel, note, velocity) + + def stop_note(self, channel: int, note: int): + """Stop a note on the specified channel""" + voice_key = (channel, note) + if voice_key in self.active_voices: + del self.active_voices[voice_key] + + # Update lighting (fade out) + self.fade_lighting(channel) + + self.note_stopped.emit(channel, note) + + def change_program(self, channel: int, program: int): + """Change the program (instrument) for a channel""" + if 1 <= channel <= 16 and 0 <= program <= 127: + self.channel_programs[channel] = program + self.program_changed.emit(channel, program) + + def set_channel_volume(self, channel: int, volume: int): + """Set channel volume (0-127)""" + if 1 <= channel <= 16 and 0 <= volume <= 127: + self.channel_volumes[channel] = volume + + # Update active voices volume + for (ch, note), voice_data in self.active_voices.items(): + if ch == channel: + channel_volume = volume / 127.0 + velocity_volume = voice_data['velocity'] / 127.0 + voice_data['volume'] = channel_volume * velocity_volume * self.master_volume + + self.volume_changed.emit(channel, volume) + + def all_notes_off(self, channel: int = None): + """Turn off all notes on a channel (or all channels if None)""" + if channel: + # Turn off notes for specific channel + to_remove = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] + else: + # Turn off all notes + to_remove = list(self.active_voices.keys()) + + for voice_key in to_remove: + ch, note = voice_key + del self.active_voices[voice_key] + self.note_stopped.emit(ch, note) + + # Update lighting + if channel: + self.lighting_state[channel] = 0.0 + else: + for ch in range(1, 17): + self.lighting_state[ch] = 0.0 + + self.lighting_updated.emit(self.lighting_state.copy()) + + def panic(self): + """Emergency stop - turn off everything""" + self.all_notes_off() + + def update_lighting(self, channel: int, velocity: int, channel_volume: float): + """Update lighting state based on note activity""" + # Calculate brightness from velocity and volume + velocity_brightness = velocity / 127.0 + brightness = velocity_brightness * channel_volume + + # Set lighting state + self.lighting_state[channel] = brightness + self.lighting_updated.emit(self.lighting_state.copy()) + + def fade_lighting(self, channel: int): + """Gradually fade lighting for a channel""" + current_brightness = self.lighting_state.get(channel, 0.0) + + # Check if there are still active notes on this channel + active_notes_on_channel = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] + + if not active_notes_on_channel: + # No active notes, fade to zero + self.lighting_state[channel] = 0.0 + else: + # Calculate brightness from remaining active notes + total_brightness = 0.0 + for (ch, note) in active_notes_on_channel: + voice_data = self.active_voices[(ch, note)] + total_brightness = max(total_brightness, voice_data['volume']) + + self.lighting_state[channel] = total_brightness + + self.lighting_updated.emit(self.lighting_state.copy()) + + def update_lighting_display(self): + """Update lighting display (called regularly from main loop)""" + # This method can be used for lighting animations or effects + # For now, just emit current state + self.lighting_updated.emit(self.lighting_state.copy()) + + def play_audio_note(self, frequency: float, waveform: str, volume: float): + """Generate and play audio for a note""" + if not self.audio_initialized_flag: + return + + try: + # Generate a short tone + duration = 0.5 # seconds + sample_count = int(self.sample_rate * duration) + + # Generate waveform + if waveform == "sine": + samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) + elif waveform == "square": + samples = np.sign(np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate)) + elif waveform == "sawtooth": + samples = 2 * (np.arange(sample_count) * frequency / self.sample_rate % 1) - 1 + elif waveform == "triangle": + t = np.arange(sample_count) * frequency / self.sample_rate % 1 + samples = 2 * np.abs(2 * t - 1) - 1 + elif waveform == "noise": + samples = np.random.uniform(-1, 1, sample_count) + else: + samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) + + # Apply volume and envelope + envelope = np.exp(-np.arange(sample_count) / (self.sample_rate * 0.3)) # Decay envelope + samples = samples * envelope * volume * 32767 + + # Convert to 16-bit integers and ensure proper shape + samples = samples.astype(np.int16) + + # Create appropriate array format based on mixer initialization + if self.stereo_mode: + # Create stereo samples (duplicate mono to both channels) + stereo_samples = np.column_stack((samples, samples)) + sound = pygame.sndarray.make_sound(stereo_samples) + else: + # Use mono samples directly + sound = pygame.sndarray.make_sound(samples) + + sound.play() + + except Exception as e: + print(f"Error playing audio note: {e}") + + @staticmethod + def midi_note_to_frequency(note: int) -> float: + """Convert MIDI note number to frequency in Hz""" + # A4 (note 69) = 440 Hz + return 440.0 * (2.0 ** ((note - 69) / 12.0)) + + def get_active_voices_count(self, channel: int = None) -> int: + """Get count of active voices""" + if channel: + return len([(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel]) + else: + return len(self.active_voices) + + def get_lighting_state(self) -> Dict[int, float]: + """Get current lighting state""" + return self.lighting_state.copy() + + def get_status(self) -> Dict: + """Get simulator status""" + return { + 'audio_initialized': self.audio_initialized_flag, + 'audio_enabled': self.audio_enabled, + 'master_volume': self.master_volume, + 'active_voices': len(self.active_voices), + 'channel_volumes': self.channel_volumes.copy(), + 'channel_programs': self.channel_programs.copy(), + 'lighting_state': self.lighting_state.copy() + } + + def cleanup(self): + """Clean up resources""" + self.all_notes_off() + if self.audio_initialized_flag: + try: + pygame.mixer.quit() + except: + pass \ No newline at end of file diff --git a/test_app.py b/test_app.py new file mode 100644 index 0000000..8f035c0 --- /dev/null +++ b/test_app.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Test script to verify the application components work without launching GUI. +""" + +import sys +import os + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_core_modules(): + """Test core application modules""" + print("Testing core modules...") + + try: + from core.midi_channel_manager import MIDIChannelManager + channel_manager = MIDIChannelManager() + print(" - MIDI Channel Manager: OK") + + from core.volume_pattern_engine import VolumePatternEngine + volume_engine = VolumePatternEngine() + print(" - Volume Pattern Engine: OK") + + from core.synth_router import SynthRouter + synth_router = SynthRouter(channel_manager) + print(" - Synth Router: OK") + + from simulator.simulator_engine import SimulatorEngine + simulator = SimulatorEngine() + print(" - Simulator Engine: OK") + + from core.output_manager import OutputManager + output_manager = OutputManager(simulator) + print(" - Output Manager: OK") + + from core.arpeggiator_engine import ArpeggiatorEngine + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + print(" - Arpeggiator Engine: OK") + + return True, (channel_manager, volume_engine, synth_router, simulator, output_manager, arpeggiator) + + except Exception as e: + print(f" - Error: {e}") + import traceback + traceback.print_exc() + return False, None + +def test_functionality(components): + """Test basic functionality""" + print("\nTesting functionality...") + + channel_manager, volume_engine, synth_router, simulator, output_manager, arpeggiator = components + + try: + # Test channel manager + channel_manager.set_active_synth_count(4) + channel_manager.set_channel_instrument(1, 10) # Drum kit + print(" - Channel management: OK") + + # Test volume patterns + volume_engine.set_pattern("swell") + volume_engine.set_pattern_speed(1.5) + volumes = volume_engine.get_all_channel_volumes(4) + print(f" - Volume patterns: OK (generated {len(volumes)} channel volumes)") + + # Test arpeggiator + arpeggiator.set_root_note(60) # Middle C + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + print(" - Arpeggiator settings: OK") + + # Test note handling (without starting playback) + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + arpeggiator.note_on(67) # G + print(f" - Note input: OK (holding {len(arpeggiator.held_notes)} notes)") + + # Test simulator + simulator.play_note(1, 60, 80) + simulator.stop_note(1, 60) + print(" - Simulator playback: OK") + + return True + + except Exception as e: + print(f" - Functionality error: {e}") + import traceback + traceback.print_exc() + return False + +def test_maschine_integration(): + """Test Maschine integration (without actual hardware)""" + print("\nTesting Maschine integration...") + + try: + from maschine.maschine_interface import MaschineInterface + from maschine.maschine_controller import MaschineController + + # Note: This won't actually connect without hardware + maschine_interface = MaschineInterface() + print(" - Maschine Interface: OK") + + # Test finding devices (will return empty list without hardware) + inputs, outputs = maschine_interface.find_maschine_devices() + print(f" - Device detection: OK (found {len(inputs)} inputs, {len(outputs)} outputs)") + + return True + + except Exception as e: + print(f" - Maschine integration error: {e}") + return False + +def main(): + """Run all tests""" + print("MIDI Arpeggiator - Application Test") + print("=" * 40) + + # Test core modules + success, components = test_core_modules() + if not success: + print("\nCore module test failed!") + return 1 + + # Test functionality + if not test_functionality(components): + print("\nFunctionality test failed!") + return 1 + + # Test Maschine integration + test_maschine_integration() + + print("\n" + "=" * 40) + print("All tests completed successfully!") + print("\nYour application is ready to run!") + print("Execute: python run.py") + + return 0 + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/test_hardware_midi.py b/test_hardware_midi.py new file mode 100644 index 0000000..0241d11 --- /dev/null +++ b/test_hardware_midi.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Test hardware MIDI output with the output manager +""" + +import sys +import os +import time + +# Add project to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +def test_output_manager_hardware(): + """Test the output manager in hardware mode""" + print("=== Testing Output Manager Hardware Mode ===") + + try: + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + + # Create output manager + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + + print(f"Available MIDI outputs: {output_manager.get_available_outputs()}") + + # Switch to hardware mode + success = output_manager.set_mode("hardware") + print(f"Switched to hardware mode: {success}") + print(f"Current mode: {output_manager.current_mode}") + + # Try to connect to first available device + outputs = output_manager.get_available_outputs() + if outputs and "No MIDI" not in outputs[0]: + device_name = outputs[0] + print(f"Connecting to: {device_name}") + + success = output_manager.set_midi_output(device_name) + print(f"Connected: {success}") + + if success: + print("Sending test notes...") + + # Send a sequence of notes + for note in [60, 64, 67, 72]: # C major chord + octave + print(f"Sending note {note}") + output_manager.send_note_on(1, note, 80) + time.sleep(0.3) + output_manager.send_note_off(1, note) + time.sleep(0.1) + + print("Hardware MIDI test completed") + return True + else: + print("No suitable MIDI outputs found") + return False + + except Exception as e: + print(f"Hardware MIDI test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_full_arpeggiator_hardware(): + """Test full arpeggiator in hardware mode""" + print("\n=== Testing Full Arpeggiator in Hardware Mode ===") + + try: + from core.midi_channel_manager import MIDIChannelManager + from core.volume_pattern_engine import VolumePatternEngine + from core.synth_router import SynthRouter + from simulator.simulator_engine import SimulatorEngine + from core.output_manager import OutputManager + from core.arpeggiator_engine import ArpeggiatorEngine + + # Create system + channel_manager = MIDIChannelManager() + volume_engine = VolumePatternEngine() + synth_router = SynthRouter(channel_manager) + simulator = SimulatorEngine() + output_manager = OutputManager(simulator) + arpeggiator = ArpeggiatorEngine(channel_manager, synth_router, volume_engine, output_manager) + + # Switch to hardware mode + output_manager.set_mode("hardware") + + # Connect to MIDI device + outputs = output_manager.get_available_outputs() + if outputs and "No MIDI" not in outputs[0]: + output_manager.set_midi_output(outputs[0]) + print(f"Connected to: {outputs[0]}") + else: + print("No MIDI device available") + return False + + # Configure arpeggiator + arpeggiator.set_root_note(60) + arpeggiator.set_scale("major") + arpeggiator.set_pattern_type("up") + arpeggiator.set_tempo(120) + + # Add notes and start + print("Adding notes and starting arpeggiator...") + arpeggiator.note_on(60) # C + arpeggiator.note_on(64) # E + + started = arpeggiator.start() + print(f"Arpeggiator started: {started}") + + if started: + print("Running arpeggiator for 5 seconds...") + time.sleep(5) + arpeggiator.stop() + print("Stopped") + return True + else: + return False + + except Exception as e: + print(f"Full hardware test failed: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + print("Hardware MIDI Test") + print("=" * 30) + + # Test output manager + om_ok = test_output_manager_hardware() + + # Test full arpeggiator + full_ok = test_full_arpeggiator_hardware() + + print("\n" + "=" * 30) + print("RESULTS:") + print(f"Output Manager: {'PASS' if om_ok else 'FAIL'}") + print(f"Full System: {'PASS' if full_ok else 'FAIL'}") + + if not om_ok: + print("\nOutput Manager issues - check MIDI device selection") + if not full_ok: + print("\nFull system issues - check arpeggiator integration") + +if __name__ == "__main__": + main() \ No newline at end of file