├── .github └── workflows │ ├── build-mac.yml │ └── build.yml ├── .gitignore ├── INSTALLATION.md ├── LICENSE ├── README.md ├── ai_studio ├── models │ └── README.txt └── modules │ ├── TMIDIX.py │ └── x_transformer_2_3_1.py ├── app.py ├── assets └── icons │ ├── app_icon.icns │ ├── app_icon.ico │ ├── app_icon.png │ ├── chevron_down.svg │ ├── clear.svg │ ├── default-plugin.svg │ ├── file.svg │ ├── pause.svg │ ├── play.svg │ └── stop.svg ├── config ├── __init__.py ├── constants.py └── theme.py ├── docs ├── assets │ ├── css │ │ └── styles.css │ ├── icons │ │ ├── app_icon.ico │ │ ├── icon-128x128.png │ │ ├── icon-72x72.png │ │ └── icon-96x96.png │ ├── img │ │ ├── ai-in-actions.png │ │ ├── ai-model-selector.png │ │ ├── image1.png │ │ ├── image2.png │ │ ├── model-config-dialog.png │ │ ├── model-downloader.png │ │ ├── piano-roll.png │ │ └── plugin-pera.png │ └── js │ │ └── main.js ├── index.html ├── pages │ ├── download.html │ ├── features.html │ └── plugins.html ├── plugin-docs.md ├── project-details.md ├── robots.txt └── sitemap.xml ├── export_utils.py ├── fluidsynth.zip ├── image1.png ├── image2.png ├── image3.png ├── install.bat ├── install.ps1 ├── install.sh ├── midi ├── __init__.py ├── device_manager.py ├── fluidsynth_player.py ├── midi_event_utils.py ├── note_scheduler.py └── playback_controller.py ├── midi_player.py ├── musecraft.png ├── note_display.py ├── plugin_api.py ├── plugin_manager.py ├── plugins ├── README.txt ├── __init__.py ├── api_helpers.py ├── geminimelody.py ├── musecraft_algopop.py ├── musecraft_chords_progressions.py ├── musecraft_piano.py └── openaimelody.py ├── pyrightconfig.json ├── pythonpackages.zip ├── requirements.txt ├── soundbank └── soundfont.sf2 ├── start.bat ├── start.sh ├── ui ├── __init__.py ├── ai_studio_panel.py ├── custom_widgets.py ├── dock_area_widget.py ├── drawing_utils.py ├── event_handlers.py ├── main_window.py ├── model_downloader │ ├── __init__.py │ ├── download_manager.py │ ├── downloader_dialog.py │ ├── error_handler.py │ ├── file_manager.py │ └── models.json ├── plugin_dialogs.py ├── plugin_panel.py └── transport_controls.py └── utils.py /.github/workflows/build-mac.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release macOS App 2 | 3 | permissions: 4 | contents: write # 👈 Required for creating releases 5 | 6 | on: 7 | workflow_dispatch: # ✅ Adds manual "Run workflow" button in Actions tab 8 | push: 9 | tags: 10 | - "v1.0.*-mac" # 🍎 Mac-specific tags (e.g., v1.0.1-mac) 11 | 12 | jobs: 13 | build-macos: 14 | runs-on: macos-latest 15 | 16 | steps: 17 | - name: 🔁 Checkout repository 18 | uses: actions/checkout@v3 19 | 20 | - name: 🐍 Set up Python 3.10 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.10" 24 | 25 | - name: 🍺 Install FluidSynth via Homebrew 26 | run: | 27 | brew update 28 | brew install fluidsynth 29 | 30 | - name: 📦 Install Python dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install pyinstaller requests 34 | pip install -r requirements.txt || echo "No requirements.txt found" 35 | 36 | - name: 🔨 Build macOS App with PyInstaller 37 | run: | 38 | pyinstaller app.py \ 39 | --windowed \ 40 | --onedir \ 41 | --name PianoRollStudio \ 42 | --icon=assets/icons/app_icon.icns \ 43 | --add-data "assets:assets" \ 44 | --add-data "soundbank:soundbank" \ 45 | --hidden-import requests \ 46 | --exclude-module tkinter \ 47 | --osx-bundle-identifier com.pianorollstudio.app 48 | 49 | - name: 📱 Create macOS App Bundle (if needed) 50 | run: | 51 | # Optional: Create a proper .app bundle structure 52 | if [ -d "dist/PianoRollStudio" ]; then 53 | echo "App bundle created successfully" 54 | ls -la dist/PianoRollStudio/ 55 | fi 56 | 57 | - name: 📂 Prepare Release Folder 58 | run: | 59 | mkdir -p release 60 | if [ -d "dist/PianoRollStudio.app" ]; then 61 | cp -R dist/PianoRollStudio.app release/ 62 | elif [ -d "dist/PianoRollStudio" ]; then 63 | cp -R dist/PianoRollStudio release/ 64 | fi 65 | if [ -f "start.sh" ]; then 66 | cp start.sh release/ 67 | chmod +x release/start.sh 68 | fi 69 | 70 | - name: 📦 Create ZIP Package for macOS 71 | run: | 72 | cd release 73 | zip -r ../PianoRollStudio-macOS.zip . 74 | cd .. 75 | 76 | - name: 🚀 Upload to GitHub Releases 77 | uses: softprops/action-gh-release@v1 78 | with: 79 | files: PianoRollStudio-macOS.zip 80 | tag_name: ${{ github.ref_name }} 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release Portable EXE 2 | 3 | permissions: 4 | contents: write # 👈 Required for creating releases 5 | 6 | on: 7 | workflow_dispatch: # ✅ Adds manual "Run workflow" button in Actions tab 8 | push: 9 | tags: 10 | - "v1.0.*" 11 | 12 | jobs: 13 | build-windows: 14 | runs-on: windows-latest 15 | 16 | steps: 17 | - name: 🔁 Checkouts repository 18 | uses: actions/checkout@v3 19 | 20 | - name: 🐍 Set up Python 3.10 21 | uses: actions/setup-python@v4 22 | with: 23 | python-version: "3.10" 24 | 25 | - name: 📦 Install Python dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install pyinstaller requests 29 | pip install -r requirements.txt || echo "No requirements.txt found" 30 | 31 | - name: 🔊 Download & Set Up FluidSynth v2.4.6 32 | run: | 33 | curl -LO https://github.com/FluidSynth/fluidsynth/releases/download/v2.4.6/fluidsynth-2.4.6-win10-x64.zip 34 | powershell -Command "Expand-Archive -Path fluidsynth-2.4.6-win10-x64.zip -DestinationPath fluidsynth" 35 | echo "${{ github.workspace }}\\fluidsynth\\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append 36 | 37 | - name: 🔨 Build Portable EXE with PyInstaller 38 | run: | 39 | pyinstaller app.py --noconsole --onefile --name PianoRollStudio --icon=assets/icons/app_icon.ico --add-data "assets;assets" --add-data "soundbank;soundbank" --hidden-import requests --exclude-module PyQt5 --exclude-module PySide2 --exclude-module PyQt6 --exclude-module PySide5 40 | 41 | - name: 📂 Prepare Release Folder 42 | run: | 43 | New-Item -ItemType Directory -Name release 44 | Copy-Item dist\PianoRollStudio.exe release\ 45 | if (Test-Path start.bat) { Copy-Item start.bat release\ } 46 | 47 | - name: 📦 Create ZIP Package 48 | run: | 49 | Compress-Archive -Path release\* -DestinationPath PianoRollStudio.zip 50 | 51 | - name: 🚀 Upload to GitHub Releases 52 | uses: softprops/action-gh-release@v1 53 | with: 54 | files: PianoRollStudio.zip 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pp# Ignore Python compiled files 2 | __pycache__/ 3 | *.pyc 4 | *.pyo 5 | 6 | # Virtual environment 7 | venv/ 8 | env/ 9 | 10 | # OS files 11 | .DS_Store 12 | Thumbs.db 13 | 14 | # IDE/project settings 15 | .vscode/ 16 | .idea/ 17 | 18 | # MIDI files 19 | *.mid 20 | 21 | # Test files 22 | test_*.py 23 | *_test.py 24 | tests/ 25 | pytest_cache/ 26 | .coverage 27 | htmlcov/ 28 | .pytest_cache/ 29 | 30 | # Old and backup files 31 | *.old 32 | *.bak 33 | *.backup 34 | *~ 35 | *.swp 36 | *.swo 37 | 38 | test_fluidsynth.py 39 | 40 | # Specific test and old files 41 | ui/old-main_window.py 42 | godzillatest.py 43 | old-note_display.py 44 | report.txt 45 | 46 | example.md 47 | 48 | check.py 49 | 50 | build/ 51 | dist/ 52 | app.spec 53 | *.spec 54 | *.pth 55 | pythonpackages/ -------------------------------------------------------------------------------- /INSTALLATION.md: -------------------------------------------------------------------------------- 1 | # 🚀 Installation Guide - MIDI Generator Piano Roll 2 | 3 | This guide provides comprehensive installation instructions for all platforms and deployment methods. 4 | 5 | ## 📋 Table of Contents 6 | 7 | - [Windows Portable Version (Recommended)](#-windows-portable-version-recommended) 8 | - [Source Installation (All Platforms)](#-source-installation-all-platforms) 9 | - [Manual Installation](#-manual-installation) 10 | - [Troubleshooting](#-troubleshooting) 11 | 12 | --- 13 | 14 | ## 🪟 Windows Portable Version (Recommended) 15 | 16 | ### Step 1: Download Portable Package 17 | 1. Visit the [Releases](https://github.com/WebChatAppAi/midi-gen/releases) page 18 | 2. Download the latest Windows portable ZIP package 19 | 3. Extract the ZIP to your desired location 20 | 21 | ### Step 2: Verify Package Contents 22 | After extraction, you should see: 23 | ``` 24 | midi-gen-portable/ 25 | ├── ai_studio/ 26 | ├── fluidsynth/ 27 | ├── plugins/ 28 | ├── pythonpackages/ 29 | ├── install.bat 30 | ├── MidiGenV2.exe 31 | └── IMPORTANT.txt 32 | ``` 33 | 34 | ### Step 3: Setup FluidSynth 35 | 1. **Create the tools directory:** 36 | ``` 37 | C:\tools\ 38 | ``` 39 | 2. **Move FluidSynth:** 40 | - Copy the `fluidsynth` folder from the extracted package 41 | - Paste it into `C:\tools\` 42 | - Final path should be: `C:\tools\fluidsynth\` 43 | 44 | ### Step 4: Install PyTorch Dependencies 45 | 1. **Double-click `install.bat`** 46 | 2. **Choose PyTorch version when prompted:** 47 | - **Option 1 (CPU)**: For systems without NVIDIA GPU 48 | - **Option 2 (CUDA/GPU)**: For systems with compatible NVIDIA GPU 49 | 3. **Wait for installation to complete** 50 | 51 | ### Step 5: Launch Application 52 | - **Option**: Double-click `MidiGenV2.exe` 53 | 54 | ### Additional Notes 55 | - Check `IMPORTANT.txt` for additional details and troubleshooting 56 | - First launch may take longer as AI models initialize 57 | 58 | --- 59 | 60 | ## 🛠️ Source Installation (All Platforms) 61 | 62 | ### Prerequisites 63 | - Python 3.10 or higher 64 | - Git 65 | - Internet connection for dependency downloads 66 | 67 | ### Step 1: Clone Repository 68 | ```bash 69 | git clone https://github.com/WebChatAppAi/midi-gen.git 70 | cd midi-gen 71 | ``` 72 | 73 | ### Step 2: Download AI Models (Optional) 74 | ```bash 75 | # Windows 76 | model_download.bat 77 | 78 | # Linux/macOS 79 | chmod +x model_download.sh 80 | ./model_download.sh 81 | ``` 82 | 83 | ### Step 3: Platform-Specific Installation 84 | 85 | #### 🪟 Windows Source Installation 86 | 87 | 1. **Extract Python Environment:** 88 | - Extract `pythonpackages.zip` to project root 89 | - This creates the `pythonpackages/` directory 90 | 91 | 2. **Run Automated Installation:** 92 | ```cmd 93 | install.bat 94 | ``` 95 | - This calls CMD → PowerShell → completes installation 96 | - Choose CPU or CUDA PyTorch when prompted 97 | 98 | 3. **Setup FluidSynth:** 99 | - Download FluidSynth from the original repo or extract `fluidsynth.zip` 100 | - Place in `C:\tools\fluidsynth\` 101 | 102 | 4. **Setup AI Models:** 103 | - Place downloaded AI models in `ai_studio\models\` 104 | 105 | 5. **Launch Application:** 106 | ```cmd 107 | start.bat 108 | # OR 109 | pythonpackages\python.exe app.py 110 | ``` 111 | 112 | #### 🐧 Linux Source Installation 113 | 114 | 1. **Run Automated Installation:** 115 | ```bash 116 | chmod +x install.sh 117 | ./install.sh 118 | ``` 119 | 120 | 2. **Verify FluidSynth:** 121 | ```bash 122 | # Install FluidSynth if not available 123 | # Ubuntu/Debian: 124 | sudo apt-get install fluidsynth libasound2-dev libjack-dev libportmidi-dev 125 | 126 | # Fedora/RHEL: 127 | sudo dnf install fluidsynth fluidsynth-devel alsa-lib-devel jack-audio-connection-kit-devel portmidi-devel 128 | 129 | # Arch/Manjaro: 130 | sudo pacman -Sy fluidsynth alsa-lib jack portmidi 131 | ``` 132 | 133 | 3. **Launch Application:** 134 | ```bash 135 | ./start.sh 136 | # OR 137 | pythonpackages/bin/python app.py 138 | ``` 139 | 140 | #### 🍎 macOS Source Installation 141 | 142 | 1. **Install Homebrew (if not installed):** 143 | ```bash 144 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 145 | ``` 146 | 147 | 2. **Run Automated Installation:** 148 | ```bash 149 | chmod +x install.sh 150 | ./install.sh 151 | ``` 152 | 153 | 3. **Install FluidSynth:** 154 | ```bash 155 | brew install fluidsynth 156 | ``` 157 | 158 | 4. **Launch Application:** 159 | ```bash 160 | chmod +x start.sh 161 | ./start.sh 162 | # OR 163 | pythonpackages/bin/python app.py 164 | ``` 165 | 166 | **Important Notes for Linux/macOS:** 167 | - Create virtual environment named exactly `pythonpackages` 168 | - Do NOT extract `pythonpackages.zip` (Windows-only) 169 | - All dependencies installed via `install.sh` 170 | 171 | --- 172 | 173 | ## ⚙️ Manual Installation 174 | 175 | ### When to Use Manual Installation 176 | - Automated `install.sh` fails 177 | - Custom Python setup required 178 | - Troubleshooting dependency issues 179 | 180 | ### Manual Steps 181 | 182 | 1. **Create Virtual Environment:** 183 | ```bash 184 | python3.10 -m venv pythonpackages 185 | ``` 186 | 187 | 2. **Activate Environment:** 188 | ```bash 189 | # Linux/macOS: 190 | source pythonpackages/bin/activate 191 | 192 | # Windows: 193 | pythonpackages\Scripts\activate 194 | ``` 195 | 196 | 3. **Install Dependencies:** 197 | ```bash 198 | pip install -r requirements.txt 199 | ``` 200 | 201 | 4. **Setup AI Studio Modules:** 202 | - Locate the 2 Python files in `ai_studio\modules\` 203 | - Copy them to your environment's site-packages: 204 | 205 | ```bash 206 | # Target location: 207 | pythonpackages/ 208 | ├── bin/ # Linux/macOS 209 | ├── Scripts/ # Windows 210 | ├── include/ 211 | ├── lib/ 212 | │ └── python3.10/ 213 | │ └── site-packages/ # <- Place the 2 files here 214 | ``` 215 | 216 | 5. **Launch Application:** 217 | ```bash 218 | python app.py 219 | ``` 220 | 221 | --- 222 | 223 | ## 🔧 Troubleshooting 224 | 225 | ### Common Issues 226 | 227 | #### FluidSynth Not Found 228 | **Windows:** 229 | - Ensure FluidSynth is at `C:\tools\fluidsynth\` 230 | - Download from releases or extract provided `fluidsynth.zip` 231 | 232 | **Linux/macOS:** 233 | - Install via package manager (see installation steps above) 234 | - Verify installation: `fluidsynth --version` 235 | 236 | #### Python Environment Issues 237 | - Ensure virtual environment is named exactly `pythonpackages` 238 | - Use Python 3.10 for best compatibility 239 | - Check that all requirements are installed: `pip list` 240 | 241 | #### PyTorch Installation Fails 242 | - Check internet connection 243 | - For Windows: try both CPU and CUDA options 244 | - Manual installation: `pip install torch torchvision torchaudio` 245 | 246 | #### Permission Errors 247 | **Windows:** 248 | - Run Command Prompt as Administrator 249 | - Check antivirus isn't blocking files 250 | 251 | **Linux/macOS:** 252 | - Use `sudo` for system package installations 253 | - Ensure execute permissions: `chmod +x install.sh start.sh` 254 | 255 | #### AI Models Not Loading 256 | - Verify models are in `ai_studio\models\` 257 | - Check file permissions 258 | - Ensure sufficient disk space 259 | 260 | ### Getting Help 261 | 262 | 1. **Check `IMPORTANT.txt`** (portable version) 263 | 2. **Review GitHub Issues**: [Issues Page](https://github.com/WebChatAppAi/midi-gen/issues) 264 | 3. **Join Discussions**: [GitHub Discussions](https://github.com/WebChatAppAi/midi-gen/discussions) 265 | 266 | --- 267 | 268 | ## 📁 Project Structure Reference 269 | 270 | ``` 271 | midi-gen/ 272 | ├── ai_studio/ 273 | │ ├── models/ # AI models location 274 | │ └── modules/ # Contains 2 Python files for manual installation 275 | ├── fluidsynth/ # FluidSynth binaries (Windows portable) 276 | ├── plugins/ # Plugin system files 277 | ├── pythonpackages/ # Virtual environment 278 | │ ├── bin/ # Python executable (Linux/macOS) 279 | │ ├── Scripts/ # Python executable (Windows) 280 | │ └── lib/python3.10/site-packages/ # Python packages 281 | ├── docs/ # Documentation 282 | ├── app.py # Main application 283 | ├── requirements.txt # Python dependencies 284 | ├── install.bat # Windows installer 285 | ├── install.sh # Linux/macOS installer 286 | ├── start.bat # Windows launcher 287 | ├── start.sh # Linux/macOS launcher 288 | └── IMPORTANT.txt # Additional notes (portable version) 289 | ``` 290 | 291 | --- 292 | 293 | *🎵 Ready to create amazing music with AI-powered MIDI generation!* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Non-Commercial Software License 2 | Version 1.0, May 2025 3 | 4 | Copyright (c) 2025 Jonas 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software "MIDI Generator Piano Roll" and associated documentation files 8 | (the "Software"), to use, copy, modify, merge, and distribute the Software, 9 | subject to the following conditions: 10 | 11 | 1. ATTRIBUTION: The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | 2. NON-COMMERCIAL USE: The Software may NOT be used for commercial purposes, 15 | including but not limited to: 16 | a. Selling the Software itself 17 | b. Incorporating the Software into a commercial product 18 | c. Using the Software to provide services for a fee 19 | d. Any activity intended to generate direct or indirect profit 20 | 21 | 3. COMMERCIAL USE PERMISSION: Commercial use of the Software is strictly prohibited 22 | without explicit written permission from Jonas (the copyright holder). To request 23 | permission, contact Jonas directly via: 24 | [jonasmikkelmind@gmail.com] 25 | 26 | 4. NOTIFICATION REQUIREMENT: Any intent to distribute or modify the Software must 27 | be communicated to the copyright holder. 28 | 29 | 5. NO WARRANTY: The Software is provided "AS IS", without warranty of any kind, 30 | express or implied, including but not limited to the warranties of merchantability, 31 | fitness for a particular purpose and noninfringement. In no event shall the 32 | authors or copyright holders be liable for any claim, damages or other liability, 33 | whether in an action of contract, tort or otherwise, arising from, out of or in 34 | connection with the Software or the use or other dealings in the Software. 35 | 36 | 6. LICENSE PRESERVATION: This license must be included in unmodified form with 37 | all distributions of the Software and derivative works. 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎵 MIDI Generator Piano Roll → MuseCraft Studio 2 | 3 |
4 | MuseCraft Studio 5 |
6 |

🎹✨ All-in-One Modern Piano Roll AI Setup

7 |

A complete piano roll with AI integrations • The next level experience

8 |

🚀 Explore the New Piano Roll Studio

9 |
10 | 11 | --- 12 | 13 | ## 🎯 **PROJECT EVOLUTION NOTICE** 14 | 15 |
16 |

🌟 MuseCraft Studio is Now Live!

17 |

The MIDI Gen project has evolved into a professional desktop application

18 | 19 |
20 |

✨ Why Upgrade to MuseCraft Studio?

21 |

🖥️ Professional Desktop App - Built with Electron, React 19 & TypeScript

22 |

🎹 Enhanced Piano Roll - Smooth velocity controls and dynamic scaling

23 |

🤖 Advanced AI Integration - More powerful models and generation options

24 |

⚡ Real-Time Performance - WebSocket-based updates and seamless playback

25 |

📱 Cross-Platform - Windows, Linux, and macOS support

26 |
27 | 28 | 29 | Get MuseCraft Studio 30 | 31 |
32 | 33 | --- 34 | 35 | ## 📖 Project Information 36 | 37 |
38 |

📄 Visit MuseCraft Studio Website

39 |

Check out the latest features, previews, and technical documentation

40 |
41 | 42 | --- 43 | 44 |
45 | 46 | Landing Page Preview 47 | 48 |
49 | 50 | --- 51 | 52 | ## ⭐ Repository Stats 53 | 54 |
55 | 56 | GitHub Stars 57 | 58 | 59 | GitHub Forks 60 | 61 | 62 | GitHub Issues 63 | 64 |
65 | 66 | 67 |
68 |

🏛️ Legacy MIDI Gen Repository

69 |
70 | 71 | Legacy Stars 72 | 73 | 74 | Legacy Forks 75 | 76 |
77 |
78 | 79 | --- 80 | 81 | ## 🎹 Key Features 82 | 83 | - 🎼 **Modern Piano Roll Editor** with smooth grid, velocity control, and dynamic scaling 84 | - 🤖 **AI-Powered Generation** for melodies, harmonies, and patterns 85 | - 🧩 **Plugin System** – Extend functionality with Python-based plugins 86 | - 🎛️ **Customizable Parameters** for fine-tuned generation 87 | - ⏯ **Real-Time Playback** synced with MIDI timeline 88 | - 📤 **Export MIDI Files** with embedded pitch/velocity data 89 | - 🌐 **Cross-Platform Builds** for Windows, Linux, and macOS 90 | 91 | --- 92 | 93 | ## 🖼️ App Previews 94 | 95 |
96 | AI Setup Preview 97 |
🤖 AI Setup Interface 98 |

99 | Dashboard Preview 100 |
📊 Dashboard Overview 101 |

102 | Musical Config Preview 103 |
🎛️ Musical Configuration Panel 104 |

105 | Velocity Notes Preview 106 |
🎹 Velocity & Notes Editor 107 |
108 | 109 | --- 110 | 111 | ## 🚀 Migration Guide 112 | 113 | ### 🔄 **Moving from MIDI Gen to MuseCraft Studio** 114 | 115 |
116 |

📥 🎯 Get MuseCraft Studio Now

117 |

Experience the evolution of AI music generation

118 |
119 | 120 | #### 🪟 **Quick Start for Windows Users** 121 | 1. **Download** the portable executable from [MuseCraft Studio Releases](https://github.com/WebChatAppAi/MuseCraft-Studio/releases) 122 | 2. **Set up** the [MuseCraftEngine](https://github.com/WebChatAppAi/MuseCraftEngine) backend 123 | 3. **Launch** and enjoy the enhanced experience 124 | 125 | #### 🐧🍎 **Linux/macOS Users** 126 | ```bash 127 | git clone https://github.com/WebChatAppAi/MuseCraft-Studio.git 128 | cd MuseCraft-Studio 129 | pnpm install 130 | pnpm dev 131 | ``` 132 | 133 |
134 |

🔗 For complete setup instructions → Visit MuseCraft Studio Repository

135 |
136 | 137 | --- 138 | 139 | ## 🙏💡 Powered by Open Source Excellence 140 | 141 |
142 | AI Model Contributor 143 |
144 |

🎵 Special Thanks to @asigalov61 for providing:

145 | 149 |

This project is made possible by the incredible open source AI music community!

150 |
151 | 152 | --- 153 | 154 | ## 🏛️ Legacy MIDI Gen Information 155 | 156 |
157 | 📚 Legacy Documentation & Features 158 | 159 | ### Original Features (Now Enhanced in MuseCraft Studio) 160 | - 🎹 **Piano Roll** with grid lines, time ruler, and MIDI notes 161 | - 🤖 **AI-Powered Generation** via Melody Model, TMIDIX & X-Transformer 162 | - 🧩 **Plugin Manager** to run motif, Markov, and custom generation logic 163 | - 🔌 **Drop-in Python Plugins** – Easily extend the app with your own `.py` files 164 | - 🛠️ **Dynamic Parameter Dialogs** – Each plugin has its own customizable settings 165 | - 📤 **Export to MIDI** with velocity/pitch embedded 166 | - ⏯ **Playback Controls** with beat-synced transport 167 | 168 | ### Legacy Installation (Deprecated) 169 | **⚠️ Note: Please use MuseCraft Studio for the latest experience** 170 | 171 | ```bash 172 | git clone https://github.com/WebChatAppAi/midi-gen.git 173 | cd midi-gen 174 | chmod +x install.sh && ./install.sh 175 | ./start.sh 176 | ``` 177 | 178 | ### Plugin System (Now Enhanced) 179 | The original plugin system has been greatly improved in MuseCraft Studio with: 180 | - Enhanced parameter controls 181 | - Real-time generation feedback 182 | - Better plugin management interface 183 | 184 |
185 | 186 | --- 187 | 188 | ## 🧠 Want to Contribute? 189 | 190 | **🎯 New Contributors**: Please contribute to [MuseCraft Studio](https://github.com/WebChatAppAi/MuseCraft-Studio) for active development! 191 | 192 | - 📖 Read the technical guide: [MuseCraft Studio Docs](https://github.com/WebChatAppAi/MuseCraft-Studio/blob/main/docs/technical.md) 193 | - 🔧 Explore the modern architecture and TypeScript codebase 194 | - 🍴 Fork → 🛠️ Build features → 📬 Open a Pull Request 195 | 196 | --- 197 | 198 | ## 📄 License 199 | 200 |
201 | Non-Commercial Software License © Jonas 202 |
203 | 204 | This project is licensed under a custom Non-Commercial Software License. See the [LICENSE](LICENSE) file in the root directory for complete license details. 205 | 206 | ### Key License Terms: 207 | - ✅ **Personal Use**: You may use and modify this software for personal and non-commercial purposes 208 | - ❌ **Commercial Restriction**: Commercial use is strictly prohibited without explicit permission from Jonas 209 | - 📧 **Distribution Notice**: You must notify the copyright holder of any distribution or modification 210 | - 🔄 **MuseCraft Studio**: The same license terms apply to the evolved MuseCraft Studio project 211 | 212 | ### Licensing Notice for MuseCraft Studio: 213 | Both MIDI Gen and MuseCraft Studio operate under the same Non-Commercial Software License. When using MuseCraft Studio, you agree to the same terms and conditions outlined above. 214 | 215 | --- 216 | 217 |
218 |

🌟 Experience the Future of AI Music Creation

219 |

🎵 MuseCraft Studio - Where AI meets professional music production 🎹✨

220 | 221 |

Made with ❤️ for the AI music community

222 |
223 | -------------------------------------------------------------------------------- /ai_studio/models/README.txt: -------------------------------------------------------------------------------- 1 | Piano Roll Studio Midi-Gen V2 - AI Models Directory 2 | ====================================== 3 | 4 | Place your AI model files in this directory. 5 | 6 | Required files: 7 | - alex_melody.pth (main AI melody model) 8 | 9 | The AI Studio will look for models in this directory. 10 | 11 | manual download link 12 | 13 | https://huggingface.co/datasets/asigalov61/misc-and-temp/resolve/main/model_checkpoint_21254_steps_0.2733_loss_0.9145_acc.pth 14 | 15 | after download just place into this folder -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import time 4 | 5 | # ===== AUDIO INITIALIZATION FIX ===== 6 | # Configure SDL audio driver before any audio-related imports to prevent startup ticks/pops 7 | os.environ["SDL_AUDIODRIVER"] = "directsound" # Use directsound for Windows 8 | 9 | def setup_embedded_python_environment(): 10 | """Setup the embedded Python environment for standalone exe""" 11 | 12 | # Get the directory where the executable/script is located 13 | if getattr(sys, 'frozen', False): 14 | # Running as a PyInstaller executable 15 | app_dir = os.path.dirname(sys.executable) 16 | print(f"🔍 Running as standalone executable from: {app_dir}") 17 | else: 18 | # Running in development mode 19 | app_dir = os.path.dirname(os.path.abspath(__file__)) 20 | print(f"🔍 Running in development mode from: {app_dir}") 21 | 22 | # Path to embedded Python environment 23 | embedded_python_dir = os.path.join(app_dir, "pythonpackages") 24 | embedded_site_packages = os.path.join(embedded_python_dir, "Lib", "site-packages") 25 | embedded_lib = os.path.join(embedded_python_dir, "Lib") 26 | 27 | if os.path.exists(embedded_python_dir): 28 | print(f"✅ Found embedded Python environment: {embedded_python_dir}") 29 | 30 | # CRITICAL: Add embedded Python paths at the VERY BEGINNING of sys.path 31 | # This ensures embedded modules are found BEFORE any bundled modules 32 | paths_to_add = [ 33 | embedded_site_packages, # FIRST: Installed packages (numpy, torch, TMIDIX, etc.) 34 | embedded_lib, # SECOND: Standard library extensions 35 | app_dir, # THIRD: Project root for local modules (ui, config, etc.) 36 | ] 37 | 38 | # Remove any existing instances of these paths first 39 | for path in paths_to_add: 40 | while path in sys.path: 41 | sys.path.remove(path) 42 | 43 | # Add paths at the beginning in reverse order (so first added ends up first) 44 | for path in reversed(paths_to_add): 45 | if os.path.exists(path): 46 | sys.path.insert(0, path) 47 | print(f"➕ Added to Python path (priority): {path}") 48 | 49 | # Verify critical modules can be found 50 | print("🧪 Verifying embedded Python modules...") 51 | try: 52 | import numpy 53 | print(f"✅ numpy found at: {numpy.__file__}") 54 | except ImportError as e: 55 | print(f"❌ numpy not found: {e}") 56 | 57 | try: 58 | import torch 59 | print(f"✅ torch found at: {torch.__file__}") 60 | except ImportError as e: 61 | print(f"❌ torch not found: {e}") 62 | 63 | print("✅ Embedded Python environment configured successfully") 64 | return True 65 | else: 66 | print(f"⚠️ Embedded Python environment not found at: {embedded_python_dir}") 67 | print(" The application will use the system Python environment") 68 | return False 69 | 70 | # Setup embedded Python environment first (before any other imports) 71 | setup_embedded_python_environment() 72 | 73 | # ===== PYGAME AUDIO PRE-INITIALIZATION ===== 74 | # Initialize pygame mixer with proper settings before any MIDI/audio modules are imported 75 | try: 76 | import pygame 77 | # Pre-initialize mixer with optimal settings to prevent audio pops 78 | pygame.mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=512) 79 | pygame.init() 80 | print("✅ Pygame audio pre-initialized successfully") 81 | 82 | # Small delay to let audio device settle 83 | time.sleep(0.1) 84 | 85 | except ImportError: 86 | print("⚠️ Pygame not available - MIDI functionality may be limited") 87 | except Exception as e: 88 | print(f"⚠️ Error during pygame audio initialization: {e}") 89 | 90 | from PySide6.QtWidgets import QApplication 91 | from PySide6.QtGui import QIcon 92 | from ui.main_window import PianoRollMainWindow 93 | from config import theme # Import theme for APP_ICON_PATH 94 | import pretty_midi 95 | from utils import ensure_ai_dependencies # Import AI dependency setup 96 | 97 | def create_required_directories(): 98 | """Create required external directories for plugins and AI studio""" 99 | try: 100 | # Get the directory where the executable/script is located 101 | if getattr(sys, 'frozen', False): 102 | # Running as a PyInstaller bundle 103 | base_dir = os.path.dirname(sys.executable) 104 | else: 105 | # Running in development mode 106 | base_dir = os.path.dirname(os.path.abspath(__file__)) 107 | 108 | # Create plugins directory 109 | plugins_dir = os.path.join(base_dir, "plugins") 110 | if not os.path.exists(plugins_dir): 111 | os.makedirs(plugins_dir) 112 | print(f"✅ Created plugins directory: {plugins_dir}") 113 | 114 | # Create ai_studio directory structure (for model files) 115 | ai_studio_dir = os.path.join(base_dir, "ai_studio") 116 | models_dir = os.path.join(ai_studio_dir, "models") 117 | 118 | for directory in [ai_studio_dir, models_dir]: 119 | if not os.path.exists(directory): 120 | os.makedirs(directory) 121 | print(f"✅ Created directory: {directory}") 122 | 123 | # Create placeholder files with instructions 124 | readme_plugins = os.path.join(plugins_dir, "README.txt") 125 | if not os.path.exists(readme_plugins): 126 | with open(readme_plugins, 'w') as f: 127 | f.write("Piano Roll Studio - Plugins Directory\n") 128 | f.write("=====================================\n\n") 129 | f.write("Place your .py plugin files in this directory.\n") 130 | f.write("The application will automatically discover and load them.\n\n") 131 | f.write("You can also install additional Python packages:\n") 132 | f.write("1. Run 'manage_python.bat' (if using portable version)\n") 133 | f.write("2. Or use: pythonpackages\\python.exe -m pip install package_name\n\n") 134 | f.write("The app will automatically use packages from the embedded Python environment.\n") 135 | 136 | readme_models = os.path.join(models_dir, "README.txt") 137 | if not os.path.exists(readme_models): 138 | with open(readme_models, 'w') as f: 139 | f.write("Piano Roll Studio - AI Models Directory\n") 140 | f.write("======================================\n\n") 141 | f.write("Place your AI model files in this directory.\n\n") 142 | f.write("Required files:\n") 143 | f.write("- alex_melody.pth (main AI melody model)\n\n") 144 | f.write("The AI Studio will look for models in this directory.\n") 145 | 146 | # Check if embedded Python environment exists 147 | embedded_python_dir = os.path.join(base_dir, "pythonpackages") 148 | if os.path.exists(embedded_python_dir): 149 | print(f"✅ Embedded Python environment found: {embedded_python_dir}") 150 | else: 151 | print(f"⚠️ Embedded Python environment not found. For full functionality, ensure 'pythonpackages' folder is present.") 152 | 153 | print(f"📁 All required directories created successfully!") 154 | return True 155 | 156 | except Exception as e: 157 | print(f"⚠️ Error creating directories: {e}") 158 | return False 159 | 160 | def main(): 161 | app = QApplication(sys.argv) 162 | 163 | # Setup AI dependencies FIRST (before any imports that need them) 164 | print("🧠 Setting up AI dependencies...") 165 | ai_setup_success = ensure_ai_dependencies() 166 | if ai_setup_success: 167 | print("✅ AI dependency setup completed successfully") 168 | else: 169 | print("⚠️ AI dependency setup failed - some AI features may not work") 170 | 171 | # Create required external directories 172 | create_required_directories() 173 | 174 | # Set application icon using the path from theme.py 175 | # theme.APP_ICON_PATH is already an absolute path resolved by get_resource_path 176 | if os.path.exists(theme.APP_ICON_PATH): 177 | app.setWindowIcon(QIcon(theme.APP_ICON_PATH)) 178 | else: 179 | print(f"Warning: Application icon not found at {theme.APP_ICON_PATH}") 180 | 181 | # app.setStyle('Fusion') # Commented out to allow custom QSS to take full effect 182 | 183 | # Optional: Load initial MIDI data if a file is specified via an environment variable or argument 184 | # For simplicity, this example does not load initial data by default. 185 | # You could add logic here to load from a default file or command-line argument. 186 | # initial_midi_file = os.environ.get("INITIAL_MIDI_FILE") 187 | # notes_to_load = [] 188 | # if initial_midi_file and os.path.exists(initial_midi_file): 189 | # try: 190 | # midi_data = pretty_midi.PrettyMIDI(initial_midi_file) 191 | # for instrument in midi_data.instruments: 192 | # if not instrument.is_drum: 193 | # notes_to_load.extend(instrument.notes) 194 | # print(f"Loaded {len(notes_to_load)} notes from {initial_midi_file}") 195 | # except Exception as e: 196 | # print(f"Error loading initial MIDI file {initial_midi_file}: {e}") 197 | # else: 198 | # print("No initial MIDI file specified or found, starting empty.") 199 | 200 | # window = PianoRollMainWindow(notes_to_load) 201 | window = PianoRollMainWindow() # Start with an empty piano roll 202 | window.show() 203 | sys.exit(app.exec()) 204 | 205 | if __name__ == '__main__': 206 | main() 207 | -------------------------------------------------------------------------------- /assets/icons/app_icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/assets/icons/app_icon.icns -------------------------------------------------------------------------------- /assets/icons/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/assets/icons/app_icon.ico -------------------------------------------------------------------------------- /assets/icons/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/assets/icons/app_icon.png -------------------------------------------------------------------------------- /assets/icons/chevron_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | -------------------------------------------------------------------------------- /assets/icons/clear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/default-plugin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/file.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/icons/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'config' directory a Python package. 2 | -------------------------------------------------------------------------------- /config/constants.py: -------------------------------------------------------------------------------- 1 | # Constants for piano roll display 2 | MIN_PITCH = 12 # C0 - Extended range to include lowest notes 3 | MAX_PITCH = 120 # C9 - Extended range to include highest notes 4 | WHITE_KEY_WIDTH = 60 5 | BLACK_KEY_WIDTH = 40 6 | WHITE_KEY_HEIGHT = 24 7 | BLACK_KEY_HEIGHT = 16 8 | BASE_TIME_SCALE = 100 # Base pixels per second (at 120 BPM) 9 | DEFAULT_BPM = 120 10 | 11 | # Constants for note labels - Full range C0 to C9 12 | MIN_LABEL_PITCH = 12 # C0 MIDI note number (will be displayed as C0) 13 | MAX_LABEL_PITCH = 120 # C9 MIDI note number (will be displayed as C9) 14 | 15 | # MIDI Program Change Constants 16 | DEFAULT_MIDI_PROGRAM = 0 # Acoustic Grand Piano (EZ Pluck) 17 | DEFAULT_MIDI_CHANNEL = 0 # Default MIDI channel for playback 18 | 19 | INSTRUMENT_PRESETS = { 20 | "EZ Pluck": 0, # Acoustic Grand Piano 21 | "Synth Lead": 80, # Lead 1 (Square) 22 | "Warm Pad": 89, # Pad 2 (Warm) 23 | "Classic Piano": 1 # Bright Acoustic Piano 24 | } 25 | 26 | # Default instrument name for UI 27 | DEFAULT_INSTRUMENT_NAME = "EZ Pluck" 28 | -------------------------------------------------------------------------------- /config/theme.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QColor 2 | 3 | # ============================================================================= 4 | # --- Color Palette --- 5 | # ============================================================================= 6 | 7 | # --- Primary Backgrounds --- 8 | # Used for main application areas, panels, and dialogs. 9 | APP_BG_COLOR = QColor(26, 26, 26) # #1A1A1A - Main application background 10 | PANEL_BG_COLOR = QColor(34, 34, 34) # #222222 - Side panels, toolbars 11 | DIALOG_BG_COLOR = QColor(42, 42, 42) # #2A2A2A - Dialog windows, pop-ups 12 | PIANO_ROLL_BG_COLOR = QColor(24, 24, 28) # #18181C - Specific for the piano roll area 13 | 14 | # --- Secondary Backgrounds / Accents (for UI elements) --- 15 | # Used for interactive elements like inputs, list items. 16 | INPUT_BG_COLOR = QColor(46, 46, 46) # #2E2E2E - Background for text inputs, combo boxes 17 | ITEM_BG_COLOR = QColor(44, 44, 44) # #2C2C2C - Background for list items, tree items 18 | ITEM_HOVER_BG_COLOR = QColor(56, 56, 56) # #383838 - Hover state for list items 19 | ITEM_SELECTED_BG_COLOR = QColor(0, 122, 204) # #007ACC - Background for selected items (using ACCENT_PRIMARY_COLOR) 20 | DRAG_OVERLAY_COLOR = QColor(0, 122, 204, 30) # #007ACC with low alpha - For drag-and-drop feedback 21 | 22 | # --- Text Colors --- 23 | PRIMARY_TEXT_COLOR = QColor(224, 224, 224) # #E0E0E0 - General text 24 | SECONDARY_TEXT_COLOR = QColor(160, 160, 160) # #A0A0A0 - Less important text, hints 25 | ACCENT_TEXT_COLOR = QColor(255, 255, 255) # #FFFFFF - Text on accent-colored backgrounds 26 | PLACEHOLDER_TEXT_COLOR = QColor(120, 120, 120) # #787878 - Placeholder text in inputs 27 | DISABLED_TEXT_COLOR = QColor(100, 100, 100) # #646464 - For foreground elements like text on disabled controls 28 | NOTE_LABEL_COLOR = QColor(255, 255, 255, 200) # Semi-transparent white for note labels 29 | 30 | # --- Accent & State Colors --- 31 | # Primary brand/action colors and their states. 32 | ACCENT_PRIMARY_COLOR = QColor(0, 122, 204) # #007ACC - Main accent color 33 | ACCENT_HOVER_COLOR = QColor(20, 142, 224) # #148EE0 - Hover state for accent elements (lighter) 34 | ACCENT_PRESSED_COLOR = QColor(0, 102, 184) # #0066B8 - Pressed state for accent elements (darker) 35 | 36 | DISABLED_BG_COLOR = QColor(50, 50, 50) # #323232 - Background for disabled controls 37 | 38 | # Standard Button Colors (for non-accented buttons) 39 | STANDARD_BUTTON_BG_COLOR = QColor(45, 45, 50) 40 | STANDARD_BUTTON_HOVER_BG_COLOR = QColor(60, 60, 65) 41 | STANDARD_BUTTON_PRESSED_BG_COLOR = QColor(35, 35, 40) 42 | STANDARD_BUTTON_TEXT_COLOR = PRIMARY_TEXT_COLOR 43 | 44 | # --- Borders & Lines --- 45 | BORDER_COLOR_NORMAL = QColor(58, 58, 58) # #3A3A3A - Default border for inputs, containers 46 | BORDER_COLOR_FOCUSED = ACCENT_PRIMARY_COLOR # Use accent color for focused input borders 47 | BORDER_COLOR_HOVER = QColor(85, 85, 85) # #555555 - Border color on hover for interactive elements 48 | 49 | # Grid Lines (Piano Roll) 50 | GRID_LINE_COLOR = QColor(40, 40, 45, 150) # #3C3C3C 51 | GRID_BEAT_LINE_COLOR = QColor(60, 60, 65, 180) # #505050 52 | GRID_MEASURE_LINE_COLOR = QColor(95, 95, 100) # #6E6E6E 53 | GRID_ROW_HIGHLIGHT_COLOR = QColor(PANEL_BG_COLOR.lighter(105).rgb() & 0xFFFFFF | (50 << 24)) # Panel BG lighter with low alpha 54 | KEY_GRID_LINE_COLOR = GRID_LINE_COLOR.darker(110) # Subtler than main grid lines 55 | 56 | # --- Shadows --- 57 | # Define color, actual implementation via QSS or QGraphicsDropShadowEffect. 58 | SHADOW_COLOR = QColor(0, 0, 0, 70) # #000000 with alpha - For subtle depth 59 | 60 | # --- Piano Roll Specific Colors --- 61 | PLAYHEAD_COLOR = QColor(70, 130, 180) # #4682B4 - Cool Blue 62 | PLAYHEAD_TRIANGLE_COLOR = QColor(100, 160, 210) # #64A0D2 - Lighter Cool Blue 63 | PLAYHEAD_SHADOW_COLOR = QColor(0, 0, 0, 70) # #000000 with alpha - For playerhead shadow 64 | PIANO_KEY_SEPARATOR_COLOR = BORDER_COLOR_NORMAL # Consistent with other borders 65 | 66 | # Piano Key Colors 67 | WHITE_KEY_COLOR = QColor(240, 240, 240) # #F0F0F0 68 | BLACK_KEY_COLOR = QColor(30, 30, 35) # #1E1E23 69 | KEY_BORDER_COLOR = QColor(100, 100, 100, 60) # Slightly reduced alpha for subtlety 70 | PIANO_KEY_LABEL_COLOR = QColor(40, 40, 40) # Darker, more visible text on white keys 71 | PIANO_KEY_BLACK_LABEL_COLOR = QColor(240, 240, 240) # Brighter white text on black keys 72 | 73 | # Add aliases for WHITE_KEY_COLOR and BLACK_KEY_COLOR to maintain compatibility 74 | PIANO_KEY_WHITE_COLOR = WHITE_KEY_COLOR 75 | PIANO_KEY_BLACK_COLOR = BLACK_KEY_COLOR 76 | PIANO_KEY_BORDER_COLOR = KEY_BORDER_COLOR 77 | 78 | # Note Colors 79 | NOTE_LOW_COLOR = QColor(80, 200, 120) # #50C878 80 | NOTE_MED_COLOR = QColor(70, 180, 210) # #46B4D2 81 | NOTE_HIGH_COLOR = QColor(230, 120, 190) # #E678BE 82 | NOTE_BORDER_COLOR = QColor(0, 0, 0, 100) # #000000 with alpha - Subtle border for notes 83 | 84 | # ============================================================================= 85 | # --- Fonts --- 86 | # ============================================================================= 87 | FONT_FAMILY_PRIMARY = "Segoe UI" 88 | FONT_FAMILY_MONOSPACE = "Consolas" 89 | 90 | # Font Sizes (in points) 91 | FONT_SIZE_XS = 7 92 | FONT_SIZE_S = 8 93 | FONT_SIZE_M = 9 # Normal / Default 94 | FONT_SIZE_L = 10 95 | FONT_SIZE_XL = 12 # Headers 96 | 97 | # Font Weights 98 | FONT_WEIGHT_NORMAL = 400 # QFont.Normal 99 | FONT_WEIGHT_MEDIUM = 500 # QFont.Medium 100 | FONT_WEIGHT_BOLD = 700 # QFont.Bold 101 | 102 | # ============================================================================= 103 | # --- Spacing & Sizing --- 104 | # ============================================================================= 105 | # Border Radius (in pixels) 106 | BORDER_RADIUS_S = 3 107 | BORDER_RADIUS_M = 5 # Standard 108 | BORDER_RADIUS_L = 7 # For larger elements like cards or dialogs 109 | 110 | # Padding (in pixels) 111 | PADDING_XS = 2 112 | PADDING_S = 4 113 | PADDING_M = 8 # Standard 114 | PADDING_L = 12 115 | PADDING_XL = 16 116 | 117 | # Icon Sizes (in pixels) 118 | ICON_SIZE_S = 12 119 | ICON_SIZE_M = 16 # General icons 120 | ICON_SIZE_L = 20 121 | ICON_SIZE_XL = 24 # For plugin list/dialog headers 122 | 123 | # Specific UI Element Sizing 124 | PLUGIN_ROW_HEIGHT = 44 125 | 126 | # ============================================================================= 127 | # --- Icon Paths --- 128 | # ============================================================================= 129 | import os # Ensure os is imported if not already 130 | from utils import get_resource_path # Import the helper 131 | 132 | # Define the base path for assets using the portable resource path function 133 | # ASSETS_BASE_PATH will be the absolute path to the 'assets' directory 134 | ASSETS_BASE_PATH = get_resource_path("assets") 135 | 136 | # Define icon paths relative to the ASSETS_BASE_PATH 137 | ICON_DIR_NAME = "icons" # Subdirectory for icons within assets 138 | 139 | # Helper to ensure forward slashes for QSS url() paths 140 | def _qss_path(path_segments): 141 | return os.path.join(*path_segments).replace('\\', '/') 142 | 143 | PLAY_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "play.svg"]) 144 | PAUSE_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "pause.svg"]) 145 | STOP_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "stop.svg"]) 146 | CLEAR_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "clear.svg"]) 147 | FILE_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "file.svg"]) 148 | PLUGIN_ICON_PATH_DEFAULT = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "default-plugin.svg"]) 149 | DROPDOWN_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "chevron_down.svg"]) 150 | APP_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "app_icon.png"]) # For main window icon 151 | 152 | # CHECKMARK_ICON_PATH = _qss_path([ASSETS_BASE_PATH, ICON_DIR_NAME, "checkmark.svg"]) 153 | 154 | # ============================================================================= 155 | # --- Mapping Legacy Names (Informational - to be removed or refactored in usage) --- 156 | # ============================================================================= 157 | # This section is for reference during the transition. Ideally, all parts of the 158 | # application will be updated to use the new semantic names directly. 159 | 160 | # Old Name -> New Semantic Name 161 | # ---------------------------------------------------- 162 | # BG_COLOR -> PIANO_ROLL_BG_COLOR 163 | # GRID_COLOR -> GRID_LINE_COLOR 164 | # BEAT_COLOR -> GRID_BEAT_LINE_COLOR 165 | # MEASURE_COLOR -> GRID_MEASURE_LINE_COLOR 166 | # ROW_HIGHLIGHT_COLOR -> GRID_ROW_HIGHLIGHT_COLOR 167 | # KEY_GRID_COLOR -> KEY_GRID_LINE_COLOR 168 | # PLAYHEAD_COLOR (old alpha) -> PLAYHEAD_COLOR (opaque) 169 | # WHITE_KEY_COLOR -> WHITE_KEY_COLOR 170 | # BLACK_KEY_COLOR -> BLACK_KEY_COLOR 171 | # KEY_BORDER_COLOR (old alpha) -> KEY_BORDER_COLOR (updated alpha) 172 | 173 | # NOTE_COLORS (dict) -> NOTE_LOW_COLOR, NOTE_MED_COLOR, NOTE_HIGH_COLOR 174 | 175 | # PRIMARY_TEXT_COLOR (old val) -> PRIMARY_TEXT_COLOR (value updated) 176 | # SECONDARY_TEXT_COLOR (old val)-> SECONDARY_TEXT_COLOR (value updated) 177 | # ACCENT_COLOR (old val) -> ACCENT_PRIMARY_COLOR 178 | # ACCENT_HOVER_COLOR (old val) -> ACCENT_HOVER_COLOR (value updated) 179 | # ACCENT_PRESSED_COLOR (old val)-> ACCENT_PRESSED_COLOR (value updated) 180 | 181 | # BUTTON_COLOR -> STANDARD_BUTTON_BG_COLOR 182 | # BUTTON_HOVER_COLOR -> STANDARD_BUTTON_HOVER_BG_COLOR 183 | # BUTTON_PRESSED_COLOR -> STANDARD_BUTTON_PRESSED_BG_COLOR 184 | # BUTTON_TEXT_COLOR -> STANDARD_BUTTON_TEXT_COLOR 185 | 186 | # HIGHLIGHT_COLOR -> ITEM_HOVER_BG_COLOR 187 | # SELECTION_COLOR -> ITEM_SELECTED_BG_COLOR 188 | # SELECTION_TEXT_COLOR -> ACCENT_TEXT_COLOR 189 | 190 | # DIALOG_BG_COLOR (old val) -> DIALOG_BG_COLOR (value updated) 191 | # INPUT_BG_COLOR (old val) -> INPUT_BG_COLOR (value updated) 192 | # INPUT_BORDER_COLOR (old val) -> BORDER_COLOR_NORMAL 193 | # INPUT_TEXT_COLOR (old val) -> PRIMARY_TEXT_COLOR 194 | # INPUT_SELECTED_BORDER_COLOR -> BORDER_COLOR_FOCUSED 195 | 196 | # DRAG_OVERLAY_COLOR (old val) -> DRAG_OVERLAY_COLOR (value updated with ACCENT_PRIMARY_COLOR + alpha) 197 | 198 | # FONT_FAMILY (old) -> FONT_FAMILY_PRIMARY 199 | # FONT_SIZE_NORMAL -> FONT_SIZE_M 200 | # FONT_SIZE_LARGE -> FONT_SIZE_L 201 | # FONT_WEIGHT_BOLD (string) -> FONT_WEIGHT_BOLD (integer) 202 | 203 | # BORDER_RADIUS (old) -> BORDER_RADIUS_M 204 | # PADDING_SMALL (old) -> PADDING_S 205 | # PADDING_MEDIUM (old) -> PADDING_M 206 | # PADDING_LARGE (old) -> PADDING_L 207 | 208 | # ICON_SIZE (old) -> ICON_SIZE_M 209 | # PLUGIN_ICON_SIZE (old) -> ICON_SIZE_XL 210 | # (PLUGIN_ROW_HEIGHT remains) 211 | 212 | # Removed old SLIDER_TRACK_COLOR, SLIDER_HANDLE_COLOR etc. 213 | # ModernSlider uses ACCENT colors for handle and theme.INPUT_BG_COLOR or specific for track. 214 | -------------------------------------------------------------------------------- /docs/assets/icons/app_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/icons/app_icon.ico -------------------------------------------------------------------------------- /docs/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /docs/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /docs/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /docs/assets/img/ai-in-actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/ai-in-actions.png -------------------------------------------------------------------------------- /docs/assets/img/ai-model-selector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/ai-model-selector.png -------------------------------------------------------------------------------- /docs/assets/img/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/image1.png -------------------------------------------------------------------------------- /docs/assets/img/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/image2.png -------------------------------------------------------------------------------- /docs/assets/img/model-config-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/model-config-dialog.png -------------------------------------------------------------------------------- /docs/assets/img/model-downloader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/model-downloader.png -------------------------------------------------------------------------------- /docs/assets/img/piano-roll.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/piano-roll.png -------------------------------------------------------------------------------- /docs/assets/img/plugin-pera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/docs/assets/img/plugin-pera.png -------------------------------------------------------------------------------- /docs/assets/js/main.js: -------------------------------------------------------------------------------- 1 | // Main JavaScript file for MIDI Generator Piano Roll website 2 | 3 | document.addEventListener('DOMContentLoaded', function() { 4 | // Mobile menu toggle 5 | const menuToggle = document.querySelector('.menu-toggle'); 6 | const nav = document.querySelector('nav'); 7 | 8 | if (menuToggle) { 9 | menuToggle.addEventListener('click', function() { 10 | nav.classList.toggle('active'); 11 | menuToggle.classList.toggle('active'); 12 | 13 | // Toggle icon 14 | const icon = menuToggle.querySelector('i'); 15 | if (icon.classList.contains('fa-bars')) { 16 | icon.classList.remove('fa-bars'); 17 | icon.classList.add('fa-times'); 18 | } else { 19 | icon.classList.remove('fa-times'); 20 | icon.classList.add('fa-bars'); 21 | } 22 | }); 23 | } 24 | 25 | // Close mobile menu when clicking outside 26 | document.addEventListener('click', function(event) { 27 | if (nav && nav.classList.contains('active') && !nav.contains(event.target) && !menuToggle.contains(event.target)) { 28 | nav.classList.remove('active'); 29 | menuToggle.classList.remove('active'); 30 | 31 | const icon = menuToggle.querySelector('i'); 32 | icon.classList.remove('fa-times'); 33 | icon.classList.add('fa-bars'); 34 | } 35 | }); 36 | 37 | // Header shrink on scroll 38 | const header = document.querySelector('header'); 39 | let lastScrollTop = 0; 40 | 41 | window.addEventListener('scroll', function() { 42 | const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 43 | 44 | if (scrollTop > 50) { 45 | header.classList.add('shrink'); 46 | } else { 47 | header.classList.remove('shrink'); 48 | } 49 | 50 | lastScrollTop = scrollTop; 51 | }); 52 | 53 | // Scroll to top button 54 | const scrollTopBtn = document.createElement('div'); 55 | scrollTopBtn.classList.add('scroll-top'); 56 | scrollTopBtn.innerHTML = ''; 57 | document.body.appendChild(scrollTopBtn); 58 | 59 | scrollTopBtn.addEventListener('click', function() { 60 | window.scrollTo({ 61 | top: 0, 62 | behavior: 'smooth' 63 | }); 64 | }); 65 | 66 | window.addEventListener('scroll', function() { 67 | if (window.pageYOffset > 300) { 68 | scrollTopBtn.classList.add('visible'); 69 | } else { 70 | scrollTopBtn.classList.remove('visible'); 71 | } 72 | }); 73 | 74 | // Syntax highlighting for code blocks 75 | document.querySelectorAll('pre code').forEach(block => { 76 | // Simple syntax highlighting 77 | highlightSyntax(block); 78 | }); 79 | 80 | // Add smooth scroll for anchor links 81 | document.querySelectorAll('a[href^="#"]').forEach(anchor => { 82 | anchor.addEventListener('click', function(e) { 83 | e.preventDefault(); 84 | 85 | const targetId = this.getAttribute('href'); 86 | if (targetId === '#') return; 87 | 88 | const targetElement = document.querySelector(targetId); 89 | if (targetElement) { 90 | const headerOffset = 80; // Adjust based on your header height 91 | const elementPosition = targetElement.getBoundingClientRect().top; 92 | const offsetPosition = elementPosition + window.pageYOffset - headerOffset; 93 | 94 | window.scrollTo({ 95 | top: offsetPosition, 96 | behavior: 'smooth' 97 | }); 98 | 99 | // Close mobile menu if open 100 | if (nav && nav.classList.contains('active')) { 101 | nav.classList.remove('active'); 102 | menuToggle.classList.remove('active'); 103 | 104 | const icon = menuToggle.querySelector('i'); 105 | icon.classList.remove('fa-times'); 106 | icon.classList.add('fa-bars'); 107 | } 108 | } 109 | }); 110 | }); 111 | 112 | // Initialize image lazy loading 113 | lazyLoadImages(); 114 | 115 | // Add animation when elements come into view 116 | animateOnScroll(); 117 | 118 | // Initialize interactive demo 119 | initInteractiveDemo(); 120 | 121 | // Initialize stats counter animation 122 | initStatsCounter(); 123 | 124 | // Initialize hero stats counter 125 | initHeroStatsCounter(); 126 | }); 127 | 128 | // Function to highlight syntax in code blocks 129 | function highlightSyntax(codeBlock) { 130 | if (!codeBlock || !codeBlock.textContent) return; 131 | 132 | let html = codeBlock.textContent; 133 | 134 | // Python syntax highlighting (simplified) 135 | const keywords = ['class', 'def', 'return', 'import', 'from', 'self', 'super', 'None', 'True', 'False', '__init__']; 136 | const functions = ['generate', 'get_parameter_info', 'Note']; 137 | 138 | // Replace keywords with spans 139 | keywords.forEach(keyword => { 140 | const regex = new RegExp(`\\b${keyword}\\b`, 'g'); 141 | html = html.replace(regex, `${keyword}`); 142 | }); 143 | 144 | // Replace functions with spans 145 | functions.forEach(func => { 146 | const regex = new RegExp(`\\b${func}\\b`, 'g'); 147 | html = html.replace(regex, `${func}`); 148 | }); 149 | 150 | // Replace strings 151 | html = html.replace(/(["'])(.*?)\1/g, '$1$2$1'); 152 | 153 | // Replace numbers 154 | html = html.replace(/\b(\d+)\b/g, '$1'); 155 | 156 | // Replace comments 157 | html = html.replace(/(#.*)$/gm, '$1'); 158 | 159 | codeBlock.innerHTML = html; 160 | } 161 | 162 | // Function to lazy load images 163 | function lazyLoadImages() { 164 | const lazyImages = document.querySelectorAll('img[data-src]'); 165 | 166 | if ('IntersectionObserver' in window) { 167 | const imageObserver = new IntersectionObserver((entries, observer) => { 168 | entries.forEach(entry => { 169 | if (entry.isIntersecting) { 170 | const img = entry.target; 171 | img.src = img.dataset.src; 172 | img.removeAttribute('data-src'); 173 | imageObserver.unobserve(img); 174 | } 175 | }); 176 | }); 177 | 178 | lazyImages.forEach(img => { 179 | imageObserver.observe(img); 180 | }); 181 | } else { 182 | // Fallback for browsers without IntersectionObserver 183 | lazyImages.forEach(img => { 184 | img.src = img.dataset.src; 185 | img.removeAttribute('data-src'); 186 | }); 187 | } 188 | } 189 | 190 | // Function to animate elements when they come into view 191 | function animateOnScroll() { 192 | const elements = document.querySelectorAll('.animate-on-scroll'); 193 | 194 | if ('IntersectionObserver' in window) { 195 | const observer = new IntersectionObserver((entries, observer) => { 196 | entries.forEach(entry => { 197 | if (entry.isIntersecting) { 198 | entry.target.classList.add('animated'); 199 | observer.unobserve(entry.target); 200 | } 201 | }); 202 | }, { 203 | threshold: 0.1 204 | }); 205 | 206 | elements.forEach(el => { 207 | observer.observe(el); 208 | }); 209 | } else { 210 | // Fallback for browsers without IntersectionObserver 211 | elements.forEach(el => { 212 | el.classList.add('animated'); 213 | }); 214 | } 215 | } 216 | 217 | // Function to create a typed text effect 218 | function createTypedEffect(element, texts, speed = 100, delay = 2000) { 219 | if (!element) return; 220 | 221 | let textIndex = 0; 222 | let charIndex = 0; 223 | let isDeleting = false; 224 | let typingDelay = speed; 225 | 226 | function type() { 227 | const currentText = texts[textIndex]; 228 | 229 | if (isDeleting) { 230 | element.textContent = currentText.substring(0, charIndex - 1); 231 | charIndex--; 232 | typingDelay = speed / 2; // Delete faster than type 233 | } else { 234 | element.textContent = currentText.substring(0, charIndex + 1); 235 | charIndex++; 236 | typingDelay = speed; 237 | } 238 | 239 | if (!isDeleting && charIndex === currentText.length) { 240 | // Finished typing 241 | isDeleting = true; 242 | typingDelay = delay; // Wait before deleting 243 | } else if (isDeleting && charIndex === 0) { 244 | // Finished deleting 245 | isDeleting = false; 246 | textIndex = (textIndex + 1) % texts.length; // Move to next text 247 | } 248 | 249 | setTimeout(type, typingDelay); 250 | } 251 | 252 | type(); // Start the typing effect 253 | } 254 | 255 | // Demo initializer for typed effect if element exists 256 | const typedElement = document.querySelector('.typed-text'); 257 | if (typedElement) { 258 | createTypedEffect( 259 | typedElement, 260 | ['Generate MIDI with motifs', 'Create melodies with Markov chains', 'Customize your piano roll', 'Export to standard MIDI'], 261 | 100, 262 | 2000 263 | ); 264 | } 265 | 266 | // Function to initialize interactive demo 267 | function initInteractiveDemo() { 268 | const demoTabs = document.querySelectorAll('.demo-tab'); 269 | const demoPanels = document.querySelectorAll('.demo-panel'); 270 | 271 | if (!demoTabs.length || !demoPanels.length) return; 272 | 273 | demoTabs.forEach(tab => { 274 | tab.addEventListener('click', function() { 275 | const targetDemo = this.getAttribute('data-demo'); 276 | 277 | // Remove active class from all tabs and panels 278 | demoTabs.forEach(t => t.classList.remove('active')); 279 | demoPanels.forEach(p => p.classList.remove('active')); 280 | 281 | // Add active class to clicked tab 282 | this.classList.add('active'); 283 | 284 | // Show corresponding panel 285 | const targetPanel = document.getElementById(`demo-${targetDemo}`); 286 | if (targetPanel) { 287 | targetPanel.classList.add('active'); 288 | } 289 | }); 290 | }); 291 | } 292 | 293 | // Function to initialize stats counter animation 294 | function initStatsCounter() { 295 | const statNumbers = document.querySelectorAll('.stat-number'); 296 | 297 | if (!statNumbers.length) return; 298 | 299 | const animateCounter = (element) => { 300 | const target = parseInt(element.getAttribute('data-count')); 301 | const duration = 2000; // 2 seconds 302 | const step = target / (duration / 16); // 60fps 303 | let current = 0; 304 | 305 | const timer = setInterval(() => { 306 | current += step; 307 | if (current >= target) { 308 | current = target; 309 | clearInterval(timer); 310 | } 311 | 312 | // Special formatting for rating 313 | if (element.parentElement.querySelector('.stat-label').textContent.includes('Rating')) { 314 | element.textContent = current.toFixed(1); 315 | } else { 316 | element.textContent = Math.floor(current).toLocaleString(); 317 | } 318 | }, 16); 319 | }; 320 | 321 | // Use Intersection Observer to trigger animation when stats come into view 322 | if ('IntersectionObserver' in window) { 323 | const statsObserver = new IntersectionObserver((entries) => { 324 | entries.forEach(entry => { 325 | if (entry.isIntersecting) { 326 | animateCounter(entry.target); 327 | statsObserver.unobserve(entry.target); 328 | } 329 | }); 330 | }, { 331 | threshold: 0.5 332 | }); 333 | 334 | statNumbers.forEach(stat => { 335 | statsObserver.observe(stat); 336 | }); 337 | } else { 338 | // Fallback for browsers without IntersectionObserver 339 | statNumbers.forEach(stat => { 340 | animateCounter(stat); 341 | }); 342 | } 343 | } 344 | 345 | // Function to initialize hero stats counter animation 346 | function initHeroStatsCounter() { 347 | const heroStatNumbers = document.querySelectorAll('.stat-number-hero'); 348 | 349 | if (!heroStatNumbers.length) return; 350 | 351 | const animateHeroCounter = (element) => { 352 | const target = parseFloat(element.getAttribute('data-count')); 353 | const duration = 2000; 354 | const step = target / (duration / 16); 355 | let current = 0; 356 | 357 | const timer = setInterval(() => { 358 | current += step; 359 | if (current >= target) { 360 | current = target; 361 | clearInterval(timer); 362 | } 363 | 364 | // Special formatting for rating 365 | if (element.parentElement.querySelector('.stat-label-hero').textContent.includes('Rating')) { 366 | element.textContent = current.toFixed(1); 367 | } else { 368 | element.textContent = Math.floor(current).toLocaleString(); 369 | } 370 | }, 16); 371 | }; 372 | 373 | // Trigger animation after a short delay for better UX 374 | setTimeout(() => { 375 | heroStatNumbers.forEach(stat => { 376 | animateHeroCounter(stat); 377 | }); 378 | }, 1000); 379 | } 380 | 381 | // Add smooth scroll functionality for demo button 382 | document.addEventListener('click', function(e) { 383 | if (e.target.closest('.demo-btn')) { 384 | e.preventDefault(); 385 | const targetElement = document.querySelector('#interactive-demo'); 386 | if (targetElement) { 387 | const headerOffset = 80; 388 | const elementPosition = targetElement.getBoundingClientRect().top; 389 | const offsetPosition = elementPosition + window.pageYOffset - headerOffset; 390 | 391 | window.scrollTo({ 392 | top: offsetPosition, 393 | behavior: 'smooth' 394 | }); 395 | } 396 | } 397 | 398 | // Smooth scroll for scroll indicator 399 | if (e.target.closest('.scroll-indicator')) { 400 | e.preventDefault(); 401 | const featuredSection = document.querySelector('.featured-img'); 402 | if (featuredSection) { 403 | const headerOffset = 80; 404 | const elementPosition = featuredSection.getBoundingClientRect().top; 405 | const offsetPosition = elementPosition + window.pageYOffset - headerOffset; 406 | 407 | window.scrollTo({ 408 | top: offsetPosition, 409 | behavior: 'smooth' 410 | }); 411 | } 412 | } 413 | }); -------------------------------------------------------------------------------- /docs/plugin-docs.md: -------------------------------------------------------------------------------- 1 | # Piano Roll MIDI Generator Plugin Documentation 2 | 3 | This document explains how to create custom MIDI generator plugins for the Piano Roll application. 4 | 5 | ## Plugin Structure 6 | 7 | Plugins are Python files that define classes inheriting from the `PluginBase` class. Each plugin must implement a standard interface to work with the Plugin Manager. 8 | 9 | ## Basic Plugin Structure 10 | 11 | ```python 12 | # plugins/my_custom_generator.py 13 | import random 14 | import pretty_midi 15 | from plugin_api import PluginBase 16 | 17 | class MyCustomGenerator(PluginBase): 18 | """ 19 | My custom MIDI generator plugin 20 | """ 21 | 22 | def __init__(self): 23 | super().__init__() 24 | self.name = "My Custom Generator" 25 | self.description = "Generates MIDI notes using my algorithm" 26 | self.author = "Your Name" 27 | self.version = "1.0" 28 | 29 | # Define parameters 30 | self.parameters = { 31 | "param_name": { 32 | "type": "int", # Can be "int", "float", "bool", "list", or "str" 33 | "min": 0, # For numeric types 34 | "max": 100, # For numeric types 35 | "default": 50, # Default value 36 | "description": "Description of the parameter" 37 | # For "list" type, add "options": ["Option1", "Option2", ...] 38 | }, 39 | # More parameters... 40 | } 41 | 42 | def generate(self, existing_notes=None, **kwargs): 43 | """ 44 | Generate MIDI notes 45 | 46 | Args: 47 | existing_notes: Optional list of existing notes 48 | **kwargs: Parameters passed from the UI 49 | 50 | Returns: 51 | List of generated pretty_midi.Note objects 52 | """ 53 | # Extract parameters with defaults 54 | param_value = kwargs.get("param_name", self.parameters["param_name"]["default"]) 55 | 56 | # Your generation algorithm here... 57 | result = [] 58 | 59 | # Example: Create a simple note 60 | note = pretty_midi.Note( 61 | velocity=100, 62 | pitch=60, # Middle C 63 | start=0.0, 64 | end=1.0 65 | ) 66 | result.append(note) 67 | 68 | return result 69 | ``` 70 | 71 | ## Installing Your Plugin 72 | 73 | 1. Save your plugin file in the `plugins` directory. 74 | 2. Restart the application, or click "Refresh" in the Plugin Manager if available. 75 | 3. Your plugin will be automatically discovered and added to the list. 76 | 77 | ## Working with MIDI Notes 78 | 79 | The Piano Roll uses the `pretty_midi` library for MIDI note representation. Each note is a `pretty_midi.Note` object with the following properties: 80 | 81 | - `pitch`: The MIDI note number (0-127) 82 | - `velocity`: The velocity of the note (0-127) 83 | - `start`: The start time in seconds 84 | - `end`: The end time in seconds 85 | 86 | ## Parameter Types 87 | 88 | The following parameter types are supported: 89 | 90 | - `int`: Integer value with min/max range 91 | - `float`: Floating-point value with min/max range 92 | - `bool`: Boolean value (true/false) 93 | - `list`: Selection from a list of options 94 | - `str`: Text value (can be combined with options for a dropdown) 95 | 96 | Example parameter definitions: 97 | 98 | ```python 99 | # Integer parameter 100 | "tempo": { 101 | "type": "int", 102 | "min": 60, 103 | "max": 240, 104 | "default": 120, 105 | "description": "Tempo in BPM" 106 | } 107 | 108 | # Float parameter 109 | "probability": { 110 | "type": "float", 111 | "min": 0.0, 112 | "max": 1.0, 113 | "default": 0.5, 114 | "description": "Probability of note generation" 115 | } 116 | 117 | # Boolean parameter 118 | "use_existing": { 119 | "type": "bool", 120 | "default": True, 121 | "description": "Use existing notes as input" 122 | } 123 | 124 | # List parameter 125 | "scale": { 126 | "type": "list", 127 | "options": ["Major", "Minor", "Pentatonic", "Blues"], 128 | "default": "Major", 129 | "description": "Musical scale to use" 130 | } 131 | ``` 132 | 133 | ## Example Plugins 134 | 135 | The application comes with several example plugins: 136 | 137 | 1. `motif_generator.py`: Creates melodies based on motifs and variations 138 | 2. `markov_generator.py`: Uses Markov chains to generate melodies 139 | 3. `melody_generator.py`: Emotional melody generator inspired by FL Studio 140 | 4. `godzilla_piano_transformer.py`: AI-powered generation using Godzilla Piano Transformer model 141 | 142 | ### AI-Powered Plugins 143 | 144 | The `godzilla_piano_transformer.py` plugin demonstrates integration with external AI models: 145 | 146 | - Uses Gradio API to communicate with Hugging Face Spaces 147 | - Supports existing notes as input primers 148 | - Includes fallback generation if API is unavailable 149 | - Demonstrates proper error handling and retry logic 150 | - Uses helper utilities from `api_helpers.py` for common operations 151 | 152 | Study these examples to understand how to create more complex generation algorithms. 153 | 154 | ## Tips for Plugin Development 155 | 156 | 1. **Test incrementally**: Start with a simple generator and gradually add complexity. 157 | 2. **Use random seeds**: Allow users to specify a random seed for reproducible results. 158 | 3. **Handle existing notes**: Consider how your plugin will interact with existing notes. 159 | 4. **Provide clear descriptions**: Make sure your parameters have clear descriptions. 160 | 5. **Validate parameters**: Use the `validate_parameters` method to ensure valid input. 161 | 162 | ## Advanced Plugin Features 163 | 164 | For advanced plugins, you can: 165 | 166 | 1. **Create custom helper methods** within your plugin class. 167 | 2. **Use advanced music theory** concepts (scales, chords, progressions). 168 | 3. **Incorporate machine learning** algorithms if applicable. 169 | 4. **Process existing notes** to create variations or accompaniments. 170 | 5. **Integrate external APIs** for AI-powered generation. 171 | 172 | ## Working with External APIs 173 | 174 | For plugins that integrate with external APIs (like AI models), use the helper utilities in `api_helpers.py`: 175 | 176 | ```python 177 | from .api_helpers import ( 178 | ApiConnectionManager, 179 | MidiFileHandler, 180 | TempFileManager, 181 | validate_api_parameters, 182 | create_fallback_melody 183 | ) 184 | 185 | class MyAIPlugin(PluginBase): 186 | def __init__(self): 187 | super().__init__() 188 | self.connection_manager = ApiConnectionManager(max_retries=3, timeout=60) 189 | 190 | def generate(self, existing_notes=None, **kwargs): 191 | with TempFileManager() as temp_manager: 192 | # Create input MIDI file 193 | input_path = MidiFileHandler.create_temp_midi_from_notes(existing_notes) 194 | temp_manager.add_temp_file(input_path) 195 | 196 | # Make API call with retry logic 197 | result = self.connection_manager.call_with_retry(self._api_call, input_path, **kwargs) 198 | 199 | # Parse result or fallback 200 | if result: 201 | return MidiFileHandler.parse_midi_file(result) 202 | else: 203 | return create_fallback_melody() 204 | ``` 205 | 206 | ## API Helper Utilities 207 | 208 | The `api_helpers.py` module provides: 209 | 210 | - **ApiConnectionManager**: Retry logic and timeout handling 211 | - **MidiFileHandler**: MIDI file creation and parsing 212 | - **TempFileManager**: Automatic cleanup of temporary files 213 | - **validate_api_parameters**: Parameter validation and normalization 214 | - **create_fallback_melody**: Simple fallback when APIs fail 215 | 216 | Happy plugin development! 217 | -------------------------------------------------------------------------------- /docs/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | # Sitemap location 5 | Sitemap: https://webchatappai.github.io/midi-gen/sitemap.xml 6 | 7 | # Crawl-delay for polite crawling 8 | Crawl-delay: 1 -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://webchatappai.github.io/midi-gen/ 5 | 2025-01-11 6 | weekly 7 | 1.0 8 | 9 | 10 | https://webchatappai.github.io/midi-gen/pages/features.html 11 | 2025-01-11 12 | weekly 13 | 0.8 14 | 15 | 16 | https://webchatappai.github.io/midi-gen/pages/plugins.html 17 | 2025-01-11 18 | weekly 19 | 0.8 20 | 21 | 22 | https://webchatappai.github.io/midi-gen/pages/download.html 23 | 2025-01-11 24 | weekly 25 | 0.9 26 | 27 | -------------------------------------------------------------------------------- /export_utils.py: -------------------------------------------------------------------------------- 1 | # export_utils.py 2 | import os 3 | import pretty_midi 4 | from typing import List 5 | 6 | def export_to_midi(notes: List[pretty_midi.Note], filename: str, tempo: float = 120.0): 7 | """ 8 | Export notes to a MIDI file 9 | 10 | Args: 11 | notes: List of pretty_midi.Note objects 12 | filename: Path to save the MIDI file 13 | tempo: Tempo in BPM 14 | """ 15 | # Create a PrettyMIDI object 16 | midi = pretty_midi.PrettyMIDI(initial_tempo=tempo) 17 | 18 | # Create an instrument 19 | instrument = pretty_midi.Instrument(program=0) # Acoustic Grand Piano 20 | 21 | # Add the notes to the instrument 22 | for note in notes: 23 | instrument.notes.append(note) 24 | 25 | # Add the instrument to the PrettyMIDI object 26 | midi.instruments.append(instrument) 27 | 28 | # Write the MIDI file 29 | midi.write(filename) 30 | 31 | return os.path.abspath(filename) -------------------------------------------------------------------------------- /fluidsynth.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/fluidsynth.zip -------------------------------------------------------------------------------- /image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/image1.png -------------------------------------------------------------------------------- /image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/image2.png -------------------------------------------------------------------------------- /image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/image3.png -------------------------------------------------------------------------------- /install.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | REM =========================================== 3 | REM PianoRollStudio - Installation Launcher 4 | REM Launches PowerShell installer window 5 | REM =========================================== 6 | 7 | REM Launch PowerShell window directly and close CMD 8 | start "" powershell -ExecutionPolicy Bypass -File "%~dp0install.ps1" 9 | 10 | REM Exit immediately 11 | exit -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | # =========================================== 2 | # PianoRollStudio - PowerShell Installation Script 3 | # Professional Installation System 4 | # =========================================== 5 | 6 | # Set execution policy for current process 7 | Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force 8 | 9 | # Configuration Variables 10 | $PYTHON_ZIP = "pythonpackages.zip" 11 | $PYTHON_DIR = "pythonpackages" 12 | $TEMP_UNZIP = "temp_py_unpack" 13 | $LOG_FILE = "install.log" 14 | $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Definition 15 | 16 | # Set window title and location 17 | $Host.UI.RawUI.WindowTitle = "PianoRollStudio Installation" 18 | Set-Location $SCRIPT_DIR 19 | 20 | # Initialize log file 21 | "PianoRollStudio Installation Log" | Out-File -FilePath $LOG_FILE -Encoding UTF8 22 | "Started: $(Get-Date)" | Out-File -FilePath $LOG_FILE -Append -Encoding UTF8 23 | "" | Out-File -FilePath $LOG_FILE -Append -Encoding UTF8 24 | 25 | function Write-Log { 26 | param([string]$Message) 27 | $Message | Out-File -FilePath $LOG_FILE -Append -Encoding UTF8 28 | } 29 | 30 | function Write-Status { 31 | param([string]$Message, [string]$Type = "INFO") 32 | $timestamp = Get-Date -Format "HH:mm:ss" 33 | $coloredMessage = switch($Type) { 34 | "SUCCESS" { Write-Host "[$Type] $Message" -ForegroundColor Green } 35 | "ERROR" { Write-Host "[$Type] $Message" -ForegroundColor Red } 36 | "WARNING" { Write-Host "[$Type] $Message" -ForegroundColor Yellow } 37 | default { Write-Host "[$Type] $Message" -ForegroundColor Cyan } 38 | } 39 | Write-Log "[$timestamp] [$Type] $Message" 40 | } 41 | 42 | # Display header 43 | Write-Host "" 44 | Write-Host "===============================================" -ForegroundColor Magenta 45 | Write-Host " PianoRollStudio Installation System" -ForegroundColor Magenta 46 | Write-Host "===============================================" -ForegroundColor Magenta 47 | Write-Host "" 48 | Write-Host "Current directory: $(Get-Location)" -ForegroundColor Gray 49 | Write-Host "Script location: $SCRIPT_DIR" -ForegroundColor Gray 50 | Write-Host "" 51 | 52 | try { 53 | # STEP 1: Validate Python Environment 54 | Write-Host "[STEP 1/4] Validating Python Environment..." -ForegroundColor Yellow 55 | 56 | $pythonExe = Join-Path $PYTHON_DIR "python.exe" 57 | 58 | if (Test-Path $pythonExe) { 59 | Write-Status "Python environment found at $pythonExe" "SUCCESS" 60 | } 61 | elseif (Test-Path $PYTHON_ZIP) { 62 | Write-Status "Extracting Python environment from $PYTHON_ZIP..." "INFO" 63 | 64 | # Clean up previous attempts 65 | if (Test-Path $PYTHON_DIR) { 66 | Write-Status "Removing existing Python directory..." "INFO" 67 | Remove-Item -Path $PYTHON_DIR -Recurse -Force -ErrorAction SilentlyContinue 68 | } 69 | if (Test-Path $TEMP_UNZIP) { 70 | Write-Status "Removing temporary extraction directory..." "INFO" 71 | Remove-Item -Path $TEMP_UNZIP -Recurse -Force -ErrorAction SilentlyContinue 72 | } 73 | 74 | # Create temp directory and extract 75 | New-Item -ItemType Directory -Path $TEMP_UNZIP -Force | Out-Null 76 | Write-Status "Extracting ZIP contents..." "INFO" 77 | 78 | Expand-Archive -Path $PYTHON_ZIP -DestinationPath $TEMP_UNZIP -Force 79 | 80 | $extractedPythonDir = Join-Path $TEMP_UNZIP "pythonpackages" 81 | if (Test-Path $extractedPythonDir) { 82 | Move-Item -Path $extractedPythonDir -Destination $PYTHON_DIR 83 | Remove-Item -Path $TEMP_UNZIP -Recurse -Force 84 | Write-Status "Python environment extracted successfully" "SUCCESS" 85 | } else { 86 | throw "Invalid Python package structure in ZIP file. Expected 'pythonpackages' directory not found." 87 | } 88 | } else { 89 | throw "Python package file ($PYTHON_ZIP) not found. Please ensure the Python packages are available in the current directory." 90 | } 91 | 92 | # STEP 2: Validate Package Manager 93 | Write-Host "" 94 | Write-Host "[STEP 2/4] Validating Package Manager..." -ForegroundColor Yellow 95 | 96 | $pipResult = & $pythonExe -m pip --version 2>&1 97 | if ($LASTEXITCODE -ne 0) { 98 | Write-Status "Installing package manager (pip)..." "INFO" 99 | 100 | $getPipPath = Join-Path $PYTHON_DIR "get-pip.py" 101 | if (-not (Test-Path $getPipPath)) { 102 | Write-Status "Downloading pip installer..." "INFO" 103 | Invoke-WebRequest -Uri "https://bootstrap.pypa.io/get-pip.py" -OutFile $getPipPath 104 | } 105 | 106 | Write-Status "Running pip installer..." "INFO" 107 | & $pythonExe $getPipPath --quiet 108 | if ($LASTEXITCODE -ne 0) { 109 | throw "Failed to install package manager" 110 | } 111 | Write-Status "Package manager installed successfully" "SUCCESS" 112 | } else { 113 | Write-Status "Package manager is available" "SUCCESS" 114 | } 115 | 116 | # STEP 3: Install Dependencies 117 | Write-Host "" 118 | Write-Host "[STEP 3/4] Installing Application Dependencies..." -ForegroundColor Yellow 119 | 120 | Write-Status "Updating package manager..." "INFO" 121 | & $pythonExe -m pip install --upgrade pip --quiet 122 | 123 | Write-Status "Installing core dependencies..." "INFO" 124 | 125 | $packages = @( 126 | "PySide6==6.6.0", 127 | "pretty_midi==0.2.10", 128 | "mido==1.3.0", 129 | "python-rtmidi==1.5.5", 130 | "pygame==2.5.2", 131 | "pyfluidsynth==1.3.2", 132 | "numpy==1.24.3", 133 | "scikit-learn==1.3.2", 134 | "matplotlib==3.8.0", 135 | "tqdm==4.66.1", 136 | "einx==0.3.0", 137 | "einops==0.7.0", 138 | "gradio_client==1.10.2", 139 | "requests==2.31.0" 140 | "psutil" 141 | ) 142 | 143 | # Create temporary requirements file 144 | $tempReq = Join-Path $env:TEMP "pianoreq_$(Get-Random).txt" 145 | $packages | Out-File -FilePath $tempReq -Encoding UTF8 146 | 147 | # Install from requirements file 148 | & $pythonExe -m pip install -r $tempReq --quiet 149 | 150 | if ($LASTEXITCODE -ne 0) { 151 | Write-Status "Some packages failed to install, trying individually..." "WARNING" 152 | 153 | foreach ($package in $packages) { 154 | Write-Status "Installing $package individually..." "INFO" 155 | & $pythonExe -m pip install $package --quiet 156 | if ($LASTEXITCODE -ne 0) { 157 | Write-Status "Failed to install $package, attempting with --no-deps..." "WARNING" 158 | & $pythonExe -m pip install $package --no-deps --quiet 159 | } 160 | } 161 | } 162 | 163 | Remove-Item -Path $tempReq -Force -ErrorAction SilentlyContinue 164 | 165 | # STEP 4: Configure PyTorch 166 | Write-Host "" 167 | Write-Host "[STEP 4/4] Configuring PyTorch Framework..." -ForegroundColor Yellow 168 | Write-Host "" 169 | Write-Host "Please select your system configuration:" -ForegroundColor Cyan 170 | Write-Host " [1] CPU-only installation (No GPU acceleration)" -ForegroundColor White 171 | Write-Host " [2] GPU-enabled installation (NVIDIA CUDA support)" -ForegroundColor White 172 | Write-Host "" 173 | 174 | do { 175 | $choice = Read-Host "Enter your choice (1 or 2)" 176 | if ($choice -eq "1" -or $choice -eq "2") { 177 | break 178 | } 179 | Write-Status "Invalid selection. Please enter 1 or 2." "ERROR" 180 | } while ($true) 181 | 182 | if ($choice -eq "1") { 183 | Write-Host "" 184 | Write-Status "Installing PyTorch (CPU-only version)..." "INFO" 185 | & $pythonExe -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu --quiet 186 | 187 | if ($LASTEXITCODE -ne 0) { 188 | throw "Failed to install PyTorch CPU version" 189 | } 190 | Write-Status "PyTorch CPU version installed successfully" "SUCCESS" 191 | } else { 192 | Write-Host "" 193 | Write-Status "Installing PyTorch (GPU-enabled version with CUDA support)..." "INFO" 194 | & $pythonExe -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 --quiet 195 | 196 | if ($LASTEXITCODE -ne 0) { 197 | throw "Failed to install PyTorch GPU version" 198 | } 199 | Write-Status "PyTorch GPU version installed successfully" "SUCCESS" 200 | } 201 | 202 | # Installation Complete 203 | Write-Host "" 204 | Write-Host "===============================================" -ForegroundColor Green 205 | Write-Host " Installation Completed Successfully" -ForegroundColor Green 206 | Write-Host "===============================================" -ForegroundColor Green 207 | Write-Host "" 208 | Write-Host "Your PianoRollStudio environment is now ready." -ForegroundColor Green 209 | Write-Host "You can launch the application using start.bat" -ForegroundColor Green 210 | Write-Host "" 211 | Write-Log "" 212 | Write-Log "Installation completed successfully at $(Get-Date)" 213 | 214 | } catch { 215 | # Installation Failed 216 | Write-Host "" 217 | Write-Host "===============================================" -ForegroundColor Red 218 | Write-Host " Installation Failed" -ForegroundColor Red 219 | Write-Host "===============================================" -ForegroundColor Red 220 | Write-Host "" 221 | Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red 222 | Write-Host "" 223 | Write-Host "Detailed log saved to: $LOG_FILE" -ForegroundColor Yellow 224 | Write-Host "" 225 | Write-Log "" 226 | Write-Log "Installation failed at $(Get-Date)" 227 | Write-Log "Error: $($_.Exception.Message)" 228 | 229 | Write-Host "Press any key to exit..." -ForegroundColor Gray 230 | $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 231 | exit 1 232 | } 233 | 234 | Write-Host "Press any key to exit..." -ForegroundColor Gray 235 | $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 236 | exit 0 -------------------------------------------------------------------------------- /midi/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'midi' directory a Python package. 2 | -------------------------------------------------------------------------------- /midi/device_manager.py: -------------------------------------------------------------------------------- 1 | import pygame.midi 2 | import time # For device test delay 3 | 4 | class DeviceManager: 5 | """Manages MIDI output device selection and access.""" 6 | def __init__(self): 7 | if not pygame.midi.get_init(): 8 | print("🎹 Initializing pygame.midi...") 9 | pygame.midi.init() 10 | 11 | # ===== MIDI DEVICE SETTLING DELAY ===== 12 | # Add delay after pygame.midi.init to allow MIDI subsystem to settle 13 | print("🔇 Pygame MIDI initialized, allowing MIDI subsystem to settle...") 14 | time.sleep(0.1) 15 | 16 | self.output_id = self._get_default_output_id() 17 | self._log_midi_devices() 18 | self.output_device = None 19 | 20 | def _log_midi_devices(self): 21 | device_count = pygame.midi.get_count() 22 | print(f"Found {device_count} MIDI devices:") 23 | for i in range(device_count): 24 | info = pygame.midi.get_device_info(i) 25 | if info: # Check if info is not None 26 | name = info[1].decode('utf-8') if info[1] else "Unknown Device" 27 | is_input = info[2] 28 | is_output = info[3] 29 | print(f" Device {i}: {name}, Input: {is_input}, Output: {is_output}") 30 | else: 31 | print(f" Device {i}: Could not retrieve info.") 32 | print(f"Selected output ID: {self.output_id}") 33 | 34 | def _get_default_output_id(self): 35 | device_count = pygame.midi.get_count() 36 | if device_count == 0: 37 | print("No MIDI devices found!") 38 | return -1 39 | 40 | # Try to find Microsoft GS Wavetable Synth first 41 | for i in range(device_count): 42 | info = pygame.midi.get_device_info(i) 43 | if info and info[3] and info[1]: # is_output and name exists 44 | name = info[1].decode('utf-8').lower() 45 | if 'microsoft gs wavetable' in name: 46 | print(f"Selected Microsoft GS Wavetable Synth: ID {i}") 47 | return i 48 | 49 | # Fallback: Find the first available output device 50 | for i in range(device_count): 51 | info = pygame.midi.get_device_info(i) 52 | if info and info[3]: # is_output 53 | print(f"Selected first available output device: ID {i} ({info[1].decode('utf-8') if info[1] else 'N/A'})") 54 | return i 55 | 56 | # Fallback to pygame's default output ID if any 57 | try: 58 | default_id = pygame.midi.get_default_output_id() 59 | if default_id != -1: 60 | print(f"Selected pygame default output device: ID {default_id}") 61 | return default_id 62 | except pygame.midi.MidiException: # Catch if no default output 63 | pass 64 | except Exception as e: # Catch other potential errors 65 | print(f"Error getting default MIDI output ID: {e}") 66 | 67 | print("No suitable output MIDI device found.") 68 | return -1 69 | 70 | def open_device(self): 71 | """Opens the selected MIDI output device.""" 72 | if self.output_id < 0: 73 | print("Cannot open MIDI device: No output ID selected.") 74 | return None 75 | 76 | if self.output_device and self.output_device.opened: 77 | # It seems pygame.midi.Output doesn't have an 'opened' attribute. 78 | # We'll rely on re-opening or assume it's fine if already instantiated. 79 | # For safety, close if it exists and re-open. 80 | try: 81 | self.output_device.close() 82 | except: pass # Ignore errors if already closed or invalid state 83 | self.output_device = None 84 | 85 | try: 86 | self.output_device = pygame.midi.Output(self.output_id) 87 | print(f"Successfully opened MIDI output device ID: {self.output_id}") 88 | # Optional: Test the device 89 | # self.test_device() 90 | return self.output_device 91 | except Exception as e: 92 | print(f"Error opening MIDI output device ID {self.output_id}: {e}") 93 | # Attempt to find and use Microsoft GS Wavetable Synth as a fallback 94 | print("Attempting to use Microsoft GS Wavetable Synth as fallback...") 95 | original_id = self.output_id 96 | ms_synth_id = -1 97 | device_count = pygame.midi.get_count() 98 | for i in range(device_count): 99 | info = pygame.midi.get_device_info(i) 100 | if info and info[3] and info[1]: 101 | name = info[1].decode('utf-8').lower() 102 | if 'microsoft gs wavetable' in name: 103 | ms_synth_id = i 104 | break 105 | if ms_synth_id != -1 and ms_synth_id != original_id: 106 | print(f"Found Microsoft GS Wavetable Synth at ID {ms_synth_id}. Trying it.") 107 | try: 108 | self.output_id = ms_synth_id 109 | self.output_device = pygame.midi.Output(self.output_id) 110 | print(f"Successfully opened fallback MIDI output device ID: {self.output_id}") 111 | return self.output_device 112 | except Exception as e2: 113 | print(f"Error opening fallback MIDI output device ID {self.output_id}: {e2}") 114 | self.output_id = original_id # Revert if fallback failed 115 | self.output_device = None 116 | return None 117 | 118 | def test_device(self): 119 | if self.output_device: 120 | try: 121 | print("Testing MIDI output with a C4 note...") 122 | self.output_device.note_on(60, 100, 0) # Middle C, velocity 100, channel 0 123 | time.sleep(0.2) 124 | self.output_device.note_off(60, 0, 0) 125 | print("Test note sent.") 126 | except Exception as e: 127 | print(f"Error during MIDI device test: {e}") 128 | else: 129 | print("Cannot test MIDI device: Not open.") 130 | 131 | 132 | def close_device(self): 133 | """Closes the MIDI output device if it's open.""" 134 | if self.output_device: 135 | try: 136 | # Send all notes off before closing 137 | for channel in range(16): 138 | self.output_device.write_short(0xB0 + channel, 123, 0) # All notes off 139 | for pitch in range(128): 140 | self.output_device.note_off(pitch, 0, 0) 141 | 142 | self.output_device.close() 143 | print(f"Closed MIDI output device ID: {self.output_id}") 144 | except Exception as e: 145 | print(f"Error closing MIDI device: {e}") 146 | finally: 147 | self.output_device = None 148 | 149 | def get_output_device(self): 150 | """Returns the currently opened output device, attempting to open if closed.""" 151 | if not self.output_device: # or not self.output_device.opened (if attribute existed) 152 | return self.open_device() 153 | return self.output_device 154 | 155 | def __del__(self): 156 | self.close_device() 157 | # pygame.midi.quit() # Let the main application decide when to quit pygame.midi 158 | -------------------------------------------------------------------------------- /midi/midi_event_utils.py: -------------------------------------------------------------------------------- 1 | import pygame.midi 2 | import pretty_midi # For note_number_to_name in logging, if desired 3 | 4 | def send_note_on(output_device, pitch, velocity, channel=0, log=False): 5 | """Sends a MIDI note ON message.""" 6 | if output_device: 7 | try: 8 | output_device.note_on(pitch, int(velocity), channel) 9 | if log: 10 | print(f"Note ON: {pretty_midi.note_number_to_name(pitch)} (P: {pitch}, V: {int(velocity)}, Ch: {channel})") 11 | except Exception as e: 12 | print(f"Error sending note on (P: {pitch}): {e}") 13 | 14 | def send_note_off(output_device, pitch, velocity=0, channel=0, log=False): 15 | """Sends a MIDI note OFF message.""" 16 | # Velocity for note off is often 0 or 64 (release velocity), but typically ignored by synths for note termination. 17 | if output_device: 18 | try: 19 | output_device.note_off(pitch, int(velocity), channel) 20 | if log: 21 | print(f"Note OFF: {pretty_midi.note_number_to_name(pitch)} (P: {pitch}, V: {int(velocity)}, Ch: {channel})") 22 | except Exception as e: 23 | print(f"Error sending note off (P: {pitch}): {e}") 24 | 25 | def send_all_notes_off(output_device, log=False): 26 | """Sends All Notes Off messages on all channels and individual note offs.""" 27 | if not output_device: 28 | if log: print("AllNotesOff: No output device.") 29 | return 30 | 31 | if log: print("Sending All Notes Off...") 32 | try: 33 | # Standard "All Notes Off" CC message for each channel 34 | for channel in range(16): 35 | # Controller 123 (0x7B) = All Notes Off 36 | output_device.write_short(0xB0 + channel, 123, 0) 37 | 38 | # Additionally, send explicit note_off for all pitches on channel 0 as a fallback 39 | # Some devices might not respond to CC 123 thoroughly. 40 | # for pitch in range(128): 41 | # output_device.note_off(pitch, 0, 0) # Channel 0, velocity 0 42 | 43 | if log: print("All Notes Off messages sent.") 44 | except Exception as e: 45 | print(f"Error sending All Notes Off CC messages: {e}") 46 | 47 | def send_panic(output_device, log=False): 48 | """More aggressive version of all notes off, includes all sound off.""" 49 | if not output_device: 50 | if log: print("Panic: No output device.") 51 | return 52 | 53 | if log: print("Sending MIDI Panic (All Sound Off & All Notes Off)...") 54 | try: 55 | for channel in range(16): 56 | # Controller 120 (0x78) = All Sound Off 57 | output_device.write_short(0xB0 + channel, 120, 0) 58 | # Controller 123 (0x7B) = All Notes Off 59 | output_device.write_short(0xB0 + channel, 123, 0) 60 | 61 | # Explicit note_off for all pitches on all channels 62 | # for channel in range(16): 63 | # for pitch in range(128): 64 | # output_device.note_off(pitch, 0, channel) 65 | if log: print("MIDI Panic messages sent.") 66 | except Exception as e: 67 | print(f"Error sending MIDI Panic messages: {e}") 68 | -------------------------------------------------------------------------------- /midi/note_scheduler.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | import pretty_midi # For logging note names 4 | # midi_event_utils are no longer used directly by NoteScheduler for FluidSynth 5 | 6 | # Assuming FluidSynthPlayer is in midi.fluidsynth_player 7 | # from .fluidsynth_player import FluidSynthPlayer # Type hint, actual instance passed in 8 | 9 | DEFAULT_MIDI_CHANNEL = 0 # For FluidSynth playback 10 | 11 | class NoteScheduler: 12 | """Handles the timing and scheduling of MIDI note events for playback using a player backend.""" 13 | 14 | def __init__(self, notes, player_backend, get_current_time_func, tempo_scale_func, stop_flag, is_playing_flag): 15 | self.notes = sorted(notes, key=lambda note: note.start) if notes else [] 16 | self.player_backend = player_backend # This will be an instance of FluidSynthPlayer 17 | self.get_current_time = get_current_time_func 18 | self.get_tempo_scale = tempo_scale_func 19 | self.stop_flag = stop_flag 20 | self.is_playing_flag = is_playing_flag 21 | 22 | self.playback_thread = None 23 | self.notes_on = {} # Tracks currently playing notes {note_index_in_sorted_list: (pitch, channel)} 24 | self.next_note_idx = 0 25 | self.log_events = True # Enable/disable MIDI event logging 26 | 27 | def start_playback_thread(self): 28 | if not self.notes: 29 | print("NoteScheduler: No notes to play.") 30 | if callable(self.is_playing_flag): # if it's a setter function 31 | self.is_playing_flag(False) 32 | else: # assume it's a mutable list/object [value] 33 | self.is_playing_flag[0] = False 34 | return 35 | 36 | if self.playback_thread is None or not self.playback_thread.is_alive(): 37 | self.stop_flag.clear() 38 | # Reset playback state for the thread 39 | self.notes_on = {} 40 | self.next_note_idx = 0 41 | # Find the first note to play based on current time (e.g., if resuming or seeking) 42 | initial_current_time = self.get_current_time() 43 | while self.next_note_idx < len(self.notes) and \ 44 | self.notes[self.next_note_idx].start < initial_current_time: 45 | # If a note should have started but also ended before current time, skip it. 46 | # Otherwise, if it should have started and is still ongoing, it should be turned on. 47 | # This logic can be complex for robust resume/seek. For now, simple advance. 48 | self.next_note_idx += 1 49 | 50 | self.playback_thread = threading.Thread(target=self._run_schedule) 51 | self.playback_thread.daemon = True 52 | self.playback_thread.start() 53 | if self.log_events: 54 | print(f"NoteScheduler: Playback thread started. First note index: {self.next_note_idx}") 55 | else: 56 | if self.log_events: print("NoteScheduler: Playback thread already running.") 57 | 58 | 59 | def _run_schedule(self): 60 | if self.log_events: 61 | print(f"NoteScheduler: _run_schedule entered. Notes: {len(self.notes)}") 62 | if self.notes: 63 | print(f"NoteScheduler: First note: P{self.notes[0].pitch} S{self.notes[0].start} E{self.notes[0].end}") 64 | 65 | if not self.player_backend: # Check player_backend instead of output_device 66 | print("NoteScheduler: No player backend available for playback.") 67 | if callable(self.is_playing_flag): self.is_playing_flag(False) 68 | else: self.is_playing_flag[0] = False 69 | return 70 | 71 | try: 72 | while not self.stop_flag.is_set(): 73 | # Check if is_playing_flag (which might be a function call or list access) is false 74 | is_playing_check = self.is_playing_flag() if callable(self.is_playing_flag) else self.is_playing_flag[0] 75 | if not is_playing_check: # If playback was paused/stopped externally 76 | time.sleep(0.01) # Sleep briefly and re-check 77 | continue 78 | 79 | current_time = self.get_current_time() 80 | 81 | # Process note-offs 82 | notes_to_remove_from_on = [] 83 | for note_idx, (pitch, channel) in list(self.notes_on.items()): # Use list for safe iteration 84 | note = self.notes[note_idx] # Original note object for end time 85 | if current_time >= note.end: 86 | self.player_backend.noteoff(channel, pitch) 87 | if self.log_events: 88 | print(f"Note OFF: {pretty_midi.note_number_to_name(pitch)} (P: {pitch}, Ch: {channel}) sent to backend.") 89 | notes_to_remove_from_on.append(note_idx) 90 | 91 | for note_idx in notes_to_remove_from_on: 92 | if note_idx in self.notes_on: # Check if still exists (can be cleared by stop) 93 | del self.notes_on[note_idx] 94 | 95 | # Process note-ons 96 | while (self.next_note_idx < len(self.notes) and 97 | current_time >= self.notes[self.next_note_idx].start): 98 | note = self.notes[self.next_note_idx] 99 | # Check if this note is already "on" from a previous iteration (e.g. due to very short sleep or fast tempo) 100 | # This check is more relevant if notes_on stores a boolean. Here it stores start time. 101 | # The main check is `current_time < note.end`. 102 | if current_time < note.end and self.next_note_idx not in self.notes_on: 103 | # Use default channel for now, instrument selection is separate 104 | channel_to_use = DEFAULT_MIDI_CHANNEL 105 | self.player_backend.noteon(channel_to_use, note.pitch, note.velocity) 106 | if self.log_events: 107 | print(f"Note ON: {pretty_midi.note_number_to_name(note.pitch)} (P: {note.pitch}, V: {note.velocity}, Ch: {channel_to_use}) sent to backend.") 108 | self.notes_on[self.next_note_idx] = (note.pitch, channel_to_use) 109 | self.next_note_idx += 1 110 | 111 | # If all notes have been scheduled and all playing notes have ended 112 | if self.next_note_idx >= len(self.notes) and not self.notes_on: 113 | if self.log_events: print("NoteScheduler: All notes played.") 114 | if callable(self.is_playing_flag): self.is_playing_flag(False) # Signal end of playback 115 | else: self.is_playing_flag[0] = False 116 | break 117 | 118 | tempo_scale = self.get_tempo_scale() 119 | sleep_duration = min(0.01, 0.005 / max(0.1, tempo_scale)) # Ensure tempo_scale isn't too small 120 | time.sleep(sleep_duration) 121 | 122 | except Exception as e: 123 | print(f"NoteScheduler: Error in playback loop: {e}") 124 | finally: 125 | # Ensure all notes are turned off when the thread exits or is stopped 126 | if self.player_backend: 127 | self.player_backend.all_notes_off() # Use backend's all_notes_off 128 | if self.log_events: print("NoteScheduler: All notes off sent to backend via player_backend.") 129 | if self.log_events: print("NoteScheduler: Playback thread finished.") 130 | # Do not set is_playing_flag to False here if stop_flag was the cause, 131 | # as the main controller should handle that. Only if playback naturally ends. 132 | 133 | 134 | def stop_playback_thread(self): 135 | self.stop_flag.set() 136 | if self.playback_thread and self.playback_thread.is_alive(): 137 | self.playback_thread.join(timeout=0.5) # Wait briefly for thread to exit 138 | self.playback_thread = None 139 | # Crucially, turn off any lingering notes if the backend is still valid 140 | if self.player_backend: 141 | self.player_backend.all_notes_off() # Use backend's all_notes_off 142 | if self.log_events: print("NoteScheduler: All notes off sent to backend on explicit stop.") 143 | if self.log_events: print("NoteScheduler: Playback thread explicitly stopped.") 144 | 145 | def update_notes(self, notes): 146 | """Updates the notes for the scheduler. Playback should be stopped before calling this.""" 147 | if self.playback_thread and self.playback_thread.is_alive(): 148 | print("NoteScheduler: Warning - updating notes while playback thread is active. Stop playback first.") 149 | self.stop_playback_thread() # Ensure it's stopped 150 | 151 | self.notes = sorted(notes, key=lambda note: note.start) if notes else [] 152 | self.next_note_idx = 0 153 | self.notes_on = {} 154 | if self.log_events: print(f"NoteScheduler: Notes updated. Count: {len(self.notes)}") 155 | 156 | def reset_playback_position(self, position_seconds=0.0): 157 | """Resets the scheduler's internal pointers to a given time, typically 0.""" 158 | self.next_note_idx = 0 159 | while self.next_note_idx < len(self.notes) and \ 160 | self.notes[self.next_note_idx].start < position_seconds: 161 | self.next_note_idx += 1 162 | self.notes_on = {} # Clear any tracked 'on' notes 163 | if self.log_events: print(f"NoteScheduler: Playback position reset to {position_seconds}s. Next note index: {self.next_note_idx}") 164 | -------------------------------------------------------------------------------- /midi/playback_controller.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | # import pygame.midi # pygame.midi might not be directly used if FluidSynth is primary 4 | import pretty_midi # For Note object type hint 5 | # from .device_manager import DeviceManager # DeviceManager might be less relevant for FluidSynth 6 | from .note_scheduler import NoteScheduler 7 | # from .midi_event_utils import send_all_notes_off # Will use FluidSynthPlayer's method 8 | from .fluidsynth_player import FluidSynthPlayer # Import FluidSynthPlayer 9 | from config.constants import DEFAULT_MIDI_PROGRAM, DEFAULT_MIDI_CHANNEL # For default instrument 10 | 11 | class PlaybackController: 12 | """Controls MIDI playback, managing state, device, and note scheduling, now using FluidSynth.""" 13 | 14 | def __init__(self): 15 | # self.device_manager = DeviceManager() # Pygame MIDI device manager 16 | self.fluidsynth_player = FluidSynthPlayer() # Initialize FluidSynth backend 17 | 18 | if not self.fluidsynth_player.fs: # Check if FluidSynth initialized successfully 19 | print("PlaybackController: FluidSynthPlayer failed to initialize. Playback will be unavailable.") 20 | # Handle fallback or disable playback features if necessary 21 | else: 22 | print("PlaybackController: FluidSynthPlayer initialized successfully.") 23 | 24 | self.notes = [] 25 | self._is_playing_internal = False 26 | self.paused = False 27 | self.current_playback_time_sec = 0.0 # Time from start of current playback segment 28 | self.playback_start_real_time = 0.0 # time.time() when playback (re)started 29 | self.pause_start_time_sec = 0.0 # Stores current_playback_time_sec when paused 30 | 31 | self.tempo_bpm = 120.0 32 | self.tempo_scale_factor = 1.0 # (current_bpm / 120.0) 33 | 34 | self.stop_flag = threading.Event() 35 | 36 | # The NoteScheduler needs a way to know if it should be actively processing. 37 | # We pass a list containing our internal playing state. 38 | self._is_playing_for_scheduler = [self._is_playing_internal] 39 | 40 | # Scheduler is created when notes are set, or on first play 41 | self.note_scheduler = None 42 | self.log_events = True # For debugging 43 | 44 | self.master_volume: float = 0.5 # Default volume 50% (0.0 to 1.0) 45 | 46 | # Set default instrument and initial volume on FluidSynthPlayer 47 | if self.fluidsynth_player and self.fluidsynth_player.fs: 48 | self.fluidsynth_player.set_instrument(DEFAULT_MIDI_CHANNEL, DEFAULT_MIDI_PROGRAM) 49 | if hasattr(self.fluidsynth_player, 'set_gain'): 50 | self.fluidsynth_player.set_gain(self.master_volume) 51 | else: 52 | if self.log_events: print("PlaybackController: FluidSynthPlayer does not have set_gain method.") 53 | 54 | 55 | def _ensure_scheduler(self): 56 | """Creates a NoteScheduler instance if one doesn't exist.""" 57 | if not self.fluidsynth_player or not self.fluidsynth_player.fs: 58 | if self.log_events: print("PlaybackController: FluidSynthPlayer not available, cannot create scheduler.") 59 | self.note_scheduler = None 60 | return 61 | 62 | # Check if scheduler needs to be (re)created. 63 | # With FluidSynth, the "output device" is the fluidsynth_player instance itself. 64 | # So, we mainly check if the scheduler exists. 65 | if not self.note_scheduler: 66 | if self.log_events: print("PlaybackController: Creating NoteScheduler.") 67 | self.note_scheduler = NoteScheduler( 68 | notes=self.notes, 69 | player_backend=self.fluidsynth_player, # Pass FluidSynthPlayer instance 70 | get_current_time_func=self.get_current_position, 71 | tempo_scale_func=lambda: self.tempo_scale_factor, 72 | stop_flag=self.stop_flag, 73 | is_playing_flag=self._is_playing_for_scheduler 74 | ) 75 | if self.log_events: print("PlaybackController: NoteScheduler created with FluidSynthPlayer.") 76 | elif self.note_scheduler: 77 | # If scheduler exists, ensure its notes are current 78 | self.note_scheduler.update_notes(self.notes) 79 | 80 | 81 | def set_notes(self, notes: list[pretty_midi.Note]): 82 | if self.log_events: print(f"PlaybackController: Setting {len(notes)} notes.") 83 | self.stop() # Stop current playback before changing notes 84 | self.notes = notes if notes else [] 85 | self.current_playback_time_sec = 0.0 86 | self.pause_start_time_sec = 0.0 87 | if self.note_scheduler: 88 | self.note_scheduler.update_notes(self.notes) 89 | self.note_scheduler.reset_playback_position(0.0) 90 | else: 91 | self._ensure_scheduler() # Create scheduler if it wasn't there 92 | 93 | def play(self): 94 | if self.log_events: print(f"PlaybackController: Play called. Currently playing: {self._is_playing_internal}, Paused: {self.paused}") 95 | if not self.notes: 96 | if self.log_events: print("PlaybackController: No notes to play.") 97 | return 98 | 99 | self._ensure_scheduler() 100 | if not self.note_scheduler or not self.fluidsynth_player or not self.fluidsynth_player.fs: 101 | if self.log_events: print("PlaybackController: Cannot play, scheduler or FluidSynthPlayer not available.") 102 | return 103 | 104 | if self._is_playing_internal and not self.paused: # Already playing 105 | return 106 | 107 | self.stop_flag.clear() 108 | self._is_playing_internal = True 109 | self._is_playing_for_scheduler[0] = True # Update shared flag for scheduler 110 | 111 | if self.paused: # Resuming 112 | # Adjust start time to account for pause duration 113 | self.playback_start_real_time = time.time() - self.pause_start_time_sec / self.tempo_scale_factor 114 | self.paused = False 115 | if self.log_events: print(f"PlaybackController: Resuming from {self.pause_start_time_sec:.2f}s.") 116 | else: # Starting new or from a seek 117 | self.playback_start_real_time = time.time() - self.current_playback_time_sec / self.tempo_scale_factor 118 | self.note_scheduler.reset_playback_position(self.current_playback_time_sec) 119 | if self.log_events: print(f"PlaybackController: Starting playback from {self.current_playback_time_sec:.2f}s.") 120 | 121 | self.note_scheduler.start_playback_thread() 122 | 123 | 124 | def pause(self): 125 | if self.log_events: print("PlaybackController: Pause called.") 126 | if self._is_playing_internal and not self.paused: 127 | self.paused = True 128 | self._is_playing_internal = False # Stop the scheduler's active loop 129 | self._is_playing_for_scheduler[0] = False 130 | # self.note_scheduler.stop_playback_thread() # Not stopping thread, just pausing its activity 131 | 132 | self.pause_start_time_sec = self.get_current_position() # Store accurate pause time 133 | if self.log_events: print(f"PlaybackController: Paused at {self.pause_start_time_sec:.2f}s.") 134 | 135 | # Send all notes off when pausing using FluidSynthPlayer 136 | if self.fluidsynth_player and self.fluidsynth_player.fs: 137 | self.fluidsynth_player.all_notes_off() 138 | if self.log_events: print("PlaybackController: All notes off sent to FluidSynthPlayer on pause.") 139 | 140 | 141 | def stop(self): 142 | if self.log_events: print("PlaybackController: Stop called.") 143 | self._is_playing_internal = False 144 | self._is_playing_for_scheduler[0] = False 145 | self.paused = False 146 | 147 | if self.note_scheduler: 148 | self.note_scheduler.stop_playback_thread() # This will also send all notes off 149 | 150 | self.current_playback_time_sec = 0.0 151 | self.pause_start_time_sec = 0.0 152 | if self.note_scheduler: # Reset scheduler's internal state too 153 | self.note_scheduler.reset_playback_position(0.0) 154 | 155 | 156 | def seek(self, position_sec: float): 157 | if self.log_events: print(f"PlaybackController: Seek to {position_sec:.2f}s.") 158 | 159 | # Stop notes before seek, regardless of playing state, using FluidSynthPlayer 160 | if self.fluidsynth_player and self.fluidsynth_player.fs: 161 | self.fluidsynth_player.all_notes_off() 162 | if self.log_events: print("PlaybackController: All notes off sent to FluidSynthPlayer on seek.") 163 | 164 | self.current_playback_time_sec = max(0.0, position_sec) 165 | self.pause_start_time_sec = self.current_playback_time_sec # If paused, resume from here 166 | 167 | if self.note_scheduler: 168 | self.note_scheduler.reset_playback_position(self.current_playback_time_sec) 169 | 170 | # If it was playing, need to restart the thread from the new position 171 | # or if paused, update the resume point. 172 | if self._is_playing_internal or self.paused: 173 | self.playback_start_real_time = time.time() - self.current_playback_time_sec / self.tempo_scale_factor 174 | if self._is_playing_internal and not self.paused and self.note_scheduler: # Was playing, restart thread 175 | # self.note_scheduler.stop_playback_thread() # Stop existing - handled by start_playback_thread if needed 176 | self.note_scheduler.start_playback_thread() # Start new from current pos 177 | 178 | if self.log_events: print(f"PlaybackController: Seek complete. Current time: {self.current_playback_time_sec:.2f}s") 179 | 180 | 181 | def get_current_position(self) -> float: 182 | if self._is_playing_internal and not self.paused: 183 | elapsed_real_time = time.time() - self.playback_start_real_time 184 | self.current_playback_time_sec = elapsed_real_time * self.tempo_scale_factor 185 | return self.current_playback_time_sec 186 | elif self.paused: 187 | return self.pause_start_time_sec 188 | return self.current_playback_time_sec # Stopped or not yet played 189 | 190 | @property 191 | def is_playing(self) -> bool: 192 | # This property reflects if the controller *thinks* it should be playing, 193 | # which might differ slightly from the scheduler thread's instantaneous state. 194 | return self._is_playing_internal and not self.paused 195 | 196 | def toggle_playback(self): 197 | """Toggles playback between play and pause.""" 198 | if self.is_playing: 199 | if self.log_events: print("PlaybackController: Toggling playback (was playing, now pausing).") 200 | self.pause() 201 | else: 202 | # If it was paused, play will resume. If stopped, play will start from current_playback_time_sec (usually 0). 203 | if self.log_events: print("PlaybackController: Toggling playback (was not playing, now playing).") 204 | self.play() 205 | 206 | def set_tempo(self, bpm: float): 207 | if bpm <= 0: 208 | if self.log_events: print("PlaybackController: BPM must be positive.") 209 | return 210 | 211 | if self.log_events: print(f"PlaybackController: Setting tempo to {bpm} BPM.") 212 | 213 | current_pos_before_tempo_change = self.get_current_position() 214 | 215 | self.tempo_bpm = float(bpm) 216 | self.tempo_scale_factor = self.tempo_bpm / 120.0 217 | 218 | # Adjust start time to maintain current logical position with new tempo 219 | if self._is_playing_internal or self.paused: 220 | self.playback_start_real_time = time.time() - (current_pos_before_tempo_change / self.tempo_scale_factor) 221 | 222 | if self.log_events: print(f"PlaybackController: Tempo scale factor: {self.tempo_scale_factor}") 223 | 224 | def cleanup(self): 225 | """Clean up resources, especially the MIDI device.""" 226 | if self.log_events: print("PlaybackController: Cleanup called.") 227 | self.stop() # Ensure playback is stopped and thread joined 228 | 229 | if self.fluidsynth_player: 230 | self.fluidsynth_player.cleanup() 231 | if self.log_events: print("PlaybackController: FluidSynthPlayer cleaned up.") 232 | 233 | # self.device_manager.close_device() # If DeviceManager was used for pygame.midi 234 | # pygame.midi.quit() # Main application should call this at the very end if pygame.midi was used. 235 | 236 | def set_instrument(self, program_num: int, channel: int = DEFAULT_MIDI_CHANNEL): 237 | """Sets the instrument for a given channel on the FluidSynthPlayer.""" 238 | if self.fluidsynth_player and self.fluidsynth_player.fs: 239 | if self.log_events: 240 | print(f"PlaybackController: Setting instrument to program {program_num} on channel {channel}.") 241 | self.fluidsynth_player.set_instrument(channel, program_num) 242 | else: 243 | if self.log_events: 244 | print("PlaybackController: Cannot set instrument, FluidSynthPlayer not available.") 245 | 246 | def set_master_volume(self, volume_float: float): 247 | """Sets the master volume for playback.""" 248 | clamped_volume = max(0.0, min(1.0, volume_float)) 249 | self.master_volume = clamped_volume 250 | 251 | if self.fluidsynth_player and self.fluidsynth_player.fs and hasattr(self.fluidsynth_player, 'set_gain'): 252 | if self.log_events: 253 | print(f"PlaybackController: Setting master volume to {self.master_volume:.2f}") 254 | self.fluidsynth_player.set_gain(self.master_volume) 255 | elif self.log_events: 256 | print("PlaybackController: Cannot set master volume, FluidSynthPlayer or set_gain method not available.") 257 | 258 | def __del__(self): 259 | self.cleanup() 260 | -------------------------------------------------------------------------------- /midi_player.py: -------------------------------------------------------------------------------- 1 | import time # For test_play_scale sleep 2 | import pretty_midi # For Note type hint 3 | import pygame.midi # For pygame.midi.quit in main test 4 | from midi.playback_controller import PlaybackController 5 | 6 | class MidiPlayer: 7 | """ 8 | Facade class for MIDI playback functionality. 9 | Delegates most operations to PlaybackController. 10 | """ 11 | 12 | def __init__(self): 13 | self.controller = PlaybackController() 14 | # The 'notes' attribute is now primarily managed by the controller. 15 | # This class can expose it via a property if direct access is still needed by UI. 16 | # self.notes = [] # No longer directly managed here 17 | 18 | def set_notes(self, notes: list[pretty_midi.Note]): 19 | """Set the notes to be played.""" 20 | self.controller.set_notes(notes) 21 | 22 | def play(self): 23 | """Start playback of MIDI notes.""" 24 | self.controller.play() 25 | 26 | def pause(self): 27 | """Pause playback.""" 28 | self.controller.pause() 29 | 30 | def stop(self): 31 | """Stop playback and reset position.""" 32 | self.controller.stop() 33 | 34 | def seek(self, position_sec: float): 35 | """Jump to a specific position in seconds.""" 36 | self.controller.seek(position_sec) 37 | 38 | def get_current_position(self) -> float: 39 | """Get the current playback position in seconds.""" 40 | return self.controller.get_current_position() 41 | 42 | @property 43 | def is_playing(self) -> bool: 44 | """Check if playback is active.""" 45 | return self.controller.is_playing 46 | 47 | @property 48 | def notes(self) -> list[pretty_midi.Note]: 49 | """Get the current list of notes.""" 50 | return self.controller.notes 51 | 52 | # Expose paused state if needed by UI, e.g. for button state 53 | @property 54 | def paused(self) -> bool: 55 | return self.controller.paused 56 | 57 | def set_tempo(self, bpm: float): 58 | """Set the tempo in beats per minute.""" 59 | self.controller.set_tempo(bpm) 60 | 61 | def set_instrument(self, program_num: int): 62 | """Set the MIDI instrument (program change).""" 63 | # Assuming channel 0 for simplicity, or make channel configurable 64 | if hasattr(self.controller, 'set_instrument'): 65 | self.controller.set_instrument(program_num) 66 | else: 67 | print("MidiPlayer: Controller does not have set_instrument method.") 68 | 69 | def set_volume(self, volume_float: float): 70 | """Sets the master volume for playback.""" 71 | if hasattr(self.controller, 'set_master_volume'): 72 | self.controller.set_master_volume(volume_float) 73 | else: 74 | print("MidiPlayer: Controller does not have set_master_volume method.") 75 | 76 | def cleanup(self): 77 | """Clean up resources.""" 78 | self.controller.cleanup() 79 | 80 | def __del__(self): 81 | self.cleanup() 82 | 83 | def test_play_scale(self): 84 | """Test function to play a C major scale.""" 85 | from pretty_midi import Note # Keep local import for test 86 | 87 | scale_notes = [] 88 | start_time = 0.0 89 | duration = 0.5 90 | 91 | for pitch_val in [60, 62, 64, 65, 67, 69, 71, 72]: # Corrected variable name 92 | note = Note( 93 | velocity=100, 94 | pitch=pitch_val, # Corrected variable name 95 | start=start_time, 96 | end=start_time + duration 97 | ) 98 | scale_notes.append(note) 99 | start_time += duration 100 | 101 | print(f"MidiPlayer (test): Created test scale with {len(scale_notes)} notes") 102 | self.set_notes(scale_notes) 103 | self.play() 104 | return True 105 | 106 | # Test function to run if this file is executed directly 107 | if __name__ == "__main__": 108 | print("Testing MidiPlayer facade...") 109 | player = MidiPlayer() 110 | 111 | # Wait a moment for MIDI to initialize (DeviceManager handles init) 112 | time.sleep(0.5) 113 | 114 | print("\nPlaying test scale via MidiPlayer facade...") 115 | player.test_play_scale() 116 | 117 | # Keep alive for playback 118 | try: 119 | while player.is_playing or player.get_current_position() < 4.0: # Wait for scale to roughly finish 120 | time.sleep(0.1) 121 | if player.is_playing: 122 | print(f"Test playback position: {player.get_current_position():.2f}s") 123 | if player.get_current_position() >= 3.9 and player.is_playing: # Stop a bit before end 124 | player.stop() 125 | print("Test scale stopped by main.") 126 | break 127 | except KeyboardInterrupt: 128 | player.stop() 129 | print("Test interrupted.") 130 | finally: 131 | player.cleanup() # Explicit cleanup 132 | if pygame.midi.get_init(): # Quit pygame.midi if it was initialized 133 | pygame.midi.quit() 134 | 135 | print("MidiPlayer test complete.") 136 | -------------------------------------------------------------------------------- /musecraft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/musecraft.png -------------------------------------------------------------------------------- /plugin_api.py: -------------------------------------------------------------------------------- 1 | # plugin_api.py 2 | import pretty_midi 3 | from abc import ABC, abstractmethod 4 | from typing import List, Dict, Any, Optional 5 | 6 | class PluginBase(ABC): 7 | """Base class for all piano roll plugins""" 8 | 9 | def __init__(self): 10 | self.name = "Base Plugin" 11 | self.description = "Base plugin class" 12 | self.author = "Unknown" 13 | self.version = "1.0" 14 | self.parameters = {} 15 | 16 | @abstractmethod 17 | def generate(self, existing_notes: List[pretty_midi.Note] = None, **kwargs) -> List[pretty_midi.Note]: 18 | """ 19 | Generate MIDI notes based on the plugin's algorithm 20 | 21 | Args: 22 | existing_notes: Optional list of existing notes to build upon 23 | **kwargs: Additional parameters specific to the plugin 24 | 25 | Returns: 26 | List of generated pretty_midi.Note objects 27 | """ 28 | pass 29 | 30 | def get_parameter_info(self) -> Dict[str, Dict[str, Any]]: 31 | """ 32 | Return information about the plugin's parameters 33 | 34 | Returns: 35 | Dictionary mapping parameter names to their metadata 36 | Example: {"param_name": {"type": "float", "min": 0.0, "max": 1.0, "default": 0.5}} 37 | """ 38 | return self.parameters 39 | 40 | def validate_parameters(self, params: Dict[str, Any]) -> Dict[str, Any]: 41 | """ 42 | Validate and normalize parameters 43 | 44 | Args: 45 | params: Dictionary of parameter values 46 | 47 | Returns: 48 | Dictionary of validated parameter values 49 | """ 50 | validated = {} 51 | for param_name, param_value in params.items(): 52 | if param_name in self.parameters: 53 | param_info = self.parameters[param_name] 54 | # Type conversion 55 | if param_info.get("type") == "int": 56 | param_value = int(param_value) 57 | elif param_info.get("type") == "float": 58 | param_value = float(param_value) 59 | elif param_info.get("type") == "bool": 60 | param_value = bool(param_value) 61 | 62 | # Range validation 63 | if "min" in param_info and param_value < param_info["min"]: 64 | param_value = param_info["min"] 65 | if "max" in param_info and param_value > param_info["max"]: 66 | param_value = param_info["max"] 67 | 68 | validated[param_name] = param_value 69 | 70 | return validated 71 | 72 | def get_name(self) -> str: 73 | """Get the plugin name""" 74 | return self.name 75 | 76 | def get_description(self) -> str: 77 | """Get the plugin description""" 78 | return self.description 79 | 80 | def get_author(self) -> str: 81 | """Get the plugin author""" 82 | return self.author 83 | 84 | def get_version(self) -> str: 85 | """Get the plugin version""" 86 | return self.version -------------------------------------------------------------------------------- /plugin_manager.py: -------------------------------------------------------------------------------- 1 | # plugin_manager.py 2 | import os 3 | import sys 4 | import importlib 5 | import importlib.util 6 | from typing import List, Dict, Optional, Any 7 | import pretty_midi 8 | 9 | from plugin_api import PluginBase 10 | from utils import get_resource_path # Import the new helper 11 | 12 | DEFAULT_PLUGINS_DIR_NAME = "plugins" 13 | 14 | class PluginManager: 15 | """Manages the discovery, loading, and execution of plugins""" 16 | 17 | def __init__(self, plugins_dir_name: str = DEFAULT_PLUGINS_DIR_NAME): 18 | # Resolve the absolute path to the plugins directory 19 | # For plugins, we want to look in a directory next to the executable when bundled. 20 | self.plugins_dir = get_resource_path(plugins_dir_name, is_external_to_bundle=True) 21 | print(f"PluginManager: Using plugins directory: {self.plugins_dir}") 22 | 23 | self.plugins = {} # Map plugin IDs to plugin instances 24 | self.discover_plugins() 25 | 26 | def discover_plugins(self): 27 | """Discover and load all plugins from the plugins directory""" 28 | # Ensure the plugins directory exists 29 | if not os.path.exists(self.plugins_dir): 30 | os.makedirs(self.plugins_dir) 31 | 32 | # CRITICAL: Add plugins directory to BEGINNING of sys.path for exe compatibility 33 | plugins_path = os.path.abspath(self.plugins_dir) 34 | if plugins_path in sys.path: 35 | sys.path.remove(plugins_path) # Remove if already there 36 | sys.path.insert(0, plugins_path) # Insert at beginning for priority 37 | 38 | print(f"🔧 Plugin system: Added {plugins_path} to sys.path with priority") 39 | 40 | # Pre-load helper modules first (like api_helpers.py) 41 | helper_modules = [] 42 | plugin_modules = [] 43 | 44 | for filename in os.listdir(self.plugins_dir): 45 | if filename.endswith(".py") and not filename.startswith("__"): 46 | module_name = filename[:-3] # Remove .py extension 47 | # Separate helpers from actual plugins 48 | if module_name in ['api_helpers', 'helpers', 'utils', 'common']: 49 | helper_modules.append(module_name) 50 | else: 51 | plugin_modules.append(module_name) 52 | 53 | # Load helper modules first 54 | for module_name in helper_modules: 55 | self.load_helper_module(module_name) 56 | 57 | # Then load actual plugins 58 | for module_name in plugin_modules: 59 | self.load_plugin(module_name) 60 | 61 | def load_helper_module(self, module_name): 62 | """ 63 | Load a helper module (like api_helpers.py) without treating it as a plugin 64 | 65 | Args: 66 | module_name: Name of the helper module to load 67 | """ 68 | try: 69 | print(f"📚 Loading helper module: {module_name}") 70 | 71 | # Try to load module with explicit path handling 72 | module_path = os.path.join(self.plugins_dir, f"{module_name}.py") 73 | if os.path.exists(module_path): 74 | print(f"Found helper file: {module_path}") 75 | 76 | # Use importlib.util.spec_from_file_location for direct file loading 77 | spec = importlib.util.spec_from_file_location(module_name, module_path) 78 | if spec and spec.loader: 79 | module = importlib.util.module_from_spec(spec) 80 | # CRITICAL: Add to sys.modules BEFORE exec for circular imports 81 | sys.modules[module_name] = module 82 | spec.loader.exec_module(module) 83 | print(f"✅ Helper module loaded: {module_name}") 84 | else: 85 | print(f"❌ Failed to create spec for helper: {module_name}") 86 | else: 87 | print(f"⚠️ Helper file not found: {module_path}") 88 | 89 | except Exception as e: 90 | print(f"❌ Error loading helper module '{module_name}': {e}") 91 | import traceback 92 | print(f" Full traceback: {traceback.format_exc()}") 93 | 94 | def load_plugin(self, module_name): 95 | """ 96 | Load a plugin by module name 97 | 98 | Args: 99 | module_name: Name of the module to load 100 | """ 101 | try: 102 | print(f"Attempting to load plugin: {module_name}") 103 | 104 | # Try to load module with explicit path handling for bundled apps 105 | module_path = os.path.join(self.plugins_dir, f"{module_name}.py") 106 | if os.path.exists(module_path): 107 | print(f"Found plugin file: {module_path}") 108 | 109 | # Check if module is already loaded (helper modules are pre-loaded) 110 | if module_name in sys.modules: 111 | print(f"Module {module_name} already loaded, using existing") 112 | module = sys.modules[module_name] 113 | else: 114 | # Use importlib.util.spec_from_file_location for direct file loading 115 | spec = importlib.util.spec_from_file_location(module_name, module_path) 116 | if spec and spec.loader: 117 | module = importlib.util.module_from_spec(spec) 118 | sys.modules[module_name] = module # Add to sys.modules for dependency resolution 119 | spec.loader.exec_module(module) 120 | print(f"Successfully imported module: {module_name}") 121 | else: 122 | print(f"Failed to create module spec for: {module_name}") 123 | return 124 | else: 125 | # Fallback to regular import 126 | print(f"Plugin file not found at {module_path}, trying regular import") 127 | try: 128 | module = importlib.import_module(module_name) 129 | except ImportError as e: 130 | print(f"Regular import also failed: {e}") 131 | return 132 | 133 | # Look for plugin classes 134 | plugin_classes_found = 0 135 | for attr_name in dir(module): 136 | attr = getattr(module, attr_name) 137 | try: 138 | # Check if this is a plugin class (not instance) 139 | if (isinstance(attr, type) and 140 | issubclass(attr, PluginBase) and 141 | attr is not PluginBase): 142 | 143 | print(f"Found plugin class: {attr_name} in {module_name}") 144 | 145 | # Create an instance of the plugin 146 | plugin_instance = attr() 147 | plugin_id = f"{module_name}.{attr_name}" 148 | self.plugins[plugin_id] = plugin_instance 149 | plugin_classes_found += 1 150 | print(f"✅ Loaded plugin: {plugin_instance.get_name()} ({plugin_id})") 151 | 152 | except TypeError as te: 153 | # Not a class or unrelated class 154 | continue 155 | except Exception as pe: 156 | print(f"⚠️ Error instantiating plugin class {attr_name}: {pe}") 157 | continue 158 | 159 | if plugin_classes_found == 0: 160 | print(f"⚠️ No valid plugin classes found in {module_name}") 161 | 162 | except ImportError as e_imp: 163 | print(f"❌ Failed to load plugin '{module_name}' due to missing dependency:") 164 | print(f" {e_imp}") 165 | print(f" Please ensure that any libraries required by '{module_name}.py' are available.") 166 | print(f" If this is a bundled application, the library might need to be included in the PyInstaller build.") 167 | except Exception as e: 168 | print(f"❌ Error loading plugin '{module_name}': {type(e).__name__} - {e}") 169 | import traceback 170 | print(f" Full traceback: {traceback.format_exc()}") 171 | 172 | def get_plugin_list(self) -> List[Dict[str, str]]: 173 | """ 174 | Get a list of all loaded plugins 175 | 176 | Returns: 177 | List of dictionaries containing plugin information 178 | """ 179 | return [ 180 | { 181 | "id": plugin_id, 182 | "name": plugin.get_name(), 183 | "description": plugin.get_description(), 184 | "author": plugin.get_author(), 185 | "version": plugin.get_version() 186 | } 187 | for plugin_id, plugin in self.plugins.items() 188 | ] 189 | 190 | def get_plugin(self, plugin_id: str) -> Optional[PluginBase]: 191 | """ 192 | Get a plugin by ID 193 | 194 | Args: 195 | plugin_id: ID of the plugin to get 196 | 197 | Returns: 198 | Plugin instance or None if not found 199 | """ 200 | return self.plugins.get(plugin_id) 201 | 202 | def generate_notes(self, 203 | plugin_id: str, 204 | existing_notes: Optional[List[pretty_midi.Note]] = None, 205 | parameters: Optional[Dict[str, Any]] = None 206 | ) -> List[pretty_midi.Note]: 207 | """ 208 | Generate notes using a specific plugin 209 | 210 | Args: 211 | plugin_id: ID of the plugin to use 212 | existing_notes: Optional list of existing notes 213 | parameters: Optional dictionary of parameters 214 | 215 | Returns: 216 | List of generated pretty_midi.Note objects 217 | """ 218 | plugin = self.get_plugin(plugin_id) 219 | if not plugin: 220 | raise ValueError(f"Plugin not found: {plugin_id}") 221 | 222 | current_params = parameters or {} 223 | # Assuming validate_parameters can handle empty dict if params is None 224 | validated_params = plugin.validate_parameters(current_params) 225 | 226 | # Ensure an empty list is passed if existing_notes is None, 227 | # if the plugin's generate method expects a list. 228 | notes_to_pass = existing_notes if existing_notes is not None else [] 229 | 230 | return plugin.generate(notes_to_pass, **validated_params) 231 | -------------------------------------------------------------------------------- /plugins/README.txt: -------------------------------------------------------------------------------- 1 | Piano Roll Studio - Plugins Directory 2 | ===================================== 3 | 4 | Place your .py plugin files in this directory. 5 | The application will automatically discover and load them. 6 | 7 | You can also install additional Python packages: 8 | 1. Run 'manage_python.bat' (if using portable version) 9 | 2. Or use: pythonpackages\python.exe -m pip install package_name 10 | 11 | The app will automatically use packages from the embedded Python environment. 12 | -------------------------------------------------------------------------------- /plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/plugins/__init__.py -------------------------------------------------------------------------------- /plugins/api_helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helper utilities for AI-based MIDI generator plugins 3 | Provides common functionality for API integration, file handling, and MIDI processing 4 | """ 5 | 6 | import os 7 | import tempfile 8 | import time 9 | import threading 10 | from typing import List, Dict, Any, Optional, Callable 11 | import pretty_midi 12 | 13 | class ApiConnectionManager: 14 | """Manages API connections with retry logic and timeout handling""" 15 | 16 | def __init__(self, max_retries: int = 3, timeout: int = 30): 17 | self.max_retries = max_retries 18 | self.timeout = timeout 19 | self.last_error = None 20 | 21 | def call_with_retry(self, api_func: Callable, *args, **kwargs) -> Any: 22 | """ 23 | Call an API function with retry logic 24 | 25 | Args: 26 | api_func: The API function to call 27 | *args, **kwargs: Arguments to pass to the function 28 | 29 | Returns: 30 | Result of the API call or None if all retries failed 31 | """ 32 | for attempt in range(self.max_retries): 33 | try: 34 | print(f"API call attempt {attempt + 1}/{self.max_retries}") 35 | result = api_func(*args, **kwargs) 36 | return result 37 | except Exception as e: 38 | self.last_error = e 39 | print(f"Attempt {attempt + 1} failed: {e}") 40 | if attempt < self.max_retries - 1: 41 | wait_time = 2 ** attempt # Exponential backoff 42 | print(f"Waiting {wait_time} seconds before retry...") 43 | time.sleep(wait_time) 44 | 45 | print(f"All {self.max_retries} attempts failed. Last error: {self.last_error}") 46 | return None 47 | 48 | class MidiFileHandler: 49 | """Handles MIDI file operations for plugin API integration""" 50 | 51 | @staticmethod 52 | def create_temp_midi_from_notes(notes: List[pretty_midi.Note], 53 | tempo: float = 120.0, 54 | instrument_program: int = 0) -> str: 55 | """ 56 | Create a temporary MIDI file from a list of notes 57 | 58 | Args: 59 | notes: List of pretty_midi.Note objects 60 | tempo: Tempo in BPM 61 | instrument_program: MIDI program number for instrument 62 | 63 | Returns: 64 | Path to temporary MIDI file 65 | """ 66 | # Create pretty_midi object 67 | pm = pretty_midi.PrettyMIDI(initial_tempo=tempo) 68 | instrument = pretty_midi.Instrument(program=instrument_program) 69 | 70 | # Add notes to instrument 71 | for note in notes: 72 | instrument.notes.append(note) 73 | 74 | pm.instruments.append(instrument) 75 | 76 | # Create temporary file 77 | temp_fd, temp_path = tempfile.mkstemp(suffix='.mid', prefix='plugin_input_') 78 | os.close(temp_fd) 79 | 80 | # Write MIDI file 81 | pm.write(temp_path) 82 | return temp_path 83 | 84 | @staticmethod 85 | def create_primer_midi(scale_root: int = 60, 86 | scale_type: str = "major", 87 | length_seconds: float = 4.0) -> str: 88 | """ 89 | Create a simple primer MIDI file when no existing notes are available 90 | 91 | Args: 92 | scale_root: Root note of the scale (MIDI number) 93 | scale_type: Type of scale ("major", "minor", "pentatonic") 94 | length_seconds: Length of primer in seconds 95 | 96 | Returns: 97 | Path to temporary MIDI file 98 | """ 99 | # Define scale intervals 100 | scales = { 101 | "major": [0, 2, 4, 5, 7, 9, 11], 102 | "minor": [0, 2, 3, 5, 7, 8, 10], 103 | "pentatonic": [0, 2, 4, 7, 9], 104 | "blues": [0, 3, 5, 6, 7, 10] 105 | } 106 | 107 | intervals = scales.get(scale_type, scales["major"]) 108 | 109 | # Create simple ascending scale pattern 110 | notes = [] 111 | note_duration = 0.5 112 | current_time = 0.0 113 | 114 | while current_time < length_seconds: 115 | for interval in intervals: 116 | if current_time >= length_seconds: 117 | break 118 | 119 | pitch = scale_root + interval 120 | note = pretty_midi.Note( 121 | velocity=80, 122 | pitch=pitch, 123 | start=current_time, 124 | end=current_time + note_duration 125 | ) 126 | notes.append(note) 127 | current_time += note_duration 128 | 129 | return MidiFileHandler.create_temp_midi_from_notes(notes) 130 | 131 | @staticmethod 132 | def parse_midi_file(midi_path: str) -> List[pretty_midi.Note]: 133 | """ 134 | Parse a MIDI file and extract all notes 135 | 136 | Args: 137 | midi_path: Path to MIDI file 138 | 139 | Returns: 140 | List of pretty_midi.Note objects 141 | """ 142 | try: 143 | pm = pretty_midi.PrettyMIDI(midi_path) 144 | notes = [] 145 | 146 | # Extract notes from all non-drum instruments 147 | for instrument in pm.instruments: 148 | if not instrument.is_drum: 149 | notes.extend(instrument.notes) 150 | 151 | # Sort by start time 152 | notes.sort(key=lambda n: n.start) 153 | return notes 154 | 155 | except Exception as e: 156 | print(f"Error parsing MIDI file: {e}") 157 | return [] 158 | 159 | class ProgressCallback: 160 | """Handles progress reporting for long-running API calls""" 161 | 162 | def __init__(self, callback_func: Optional[Callable[[str], None]] = None): 163 | self.callback_func = callback_func or self._default_callback 164 | self.is_cancelled = False 165 | 166 | def _default_callback(self, message: str): 167 | """Default progress callback that prints to console""" 168 | print(f"Progress: {message}") 169 | 170 | def update(self, message: str): 171 | """Update progress with a message""" 172 | if not self.is_cancelled: 173 | self.callback_func(message) 174 | 175 | def cancel(self): 176 | """Cancel the operation""" 177 | self.is_cancelled = True 178 | self.update("Operation cancelled") 179 | 180 | class TempFileManager: 181 | """Context manager for handling temporary files""" 182 | 183 | def __init__(self): 184 | self.temp_files = [] 185 | 186 | def __enter__(self): 187 | return self 188 | 189 | def __exit__(self, exc_type, exc_val, exc_tb): 190 | self.cleanup() 191 | 192 | def add_temp_file(self, file_path: str): 193 | """Add a temporary file to be cleaned up""" 194 | self.temp_files.append(file_path) 195 | 196 | def cleanup(self): 197 | """Clean up all temporary files""" 198 | for file_path in self.temp_files: 199 | try: 200 | if os.path.exists(file_path): 201 | os.unlink(file_path) 202 | except Exception as e: 203 | print(f"Warning: Could not delete temporary file {file_path}: {e}") 204 | self.temp_files.clear() 205 | 206 | def validate_api_parameters(params: Dict[str, Any], 207 | param_specs: Dict[str, Dict[str, Any]]) -> Dict[str, Any]: 208 | """ 209 | Validate and normalize API parameters according to specifications 210 | 211 | Args: 212 | params: Dictionary of parameter values 213 | param_specs: Dictionary of parameter specifications 214 | 215 | Returns: 216 | Dictionary of validated parameters 217 | """ 218 | validated = {} 219 | 220 | for param_name, spec in param_specs.items(): 221 | value = params.get(param_name, spec.get("default")) 222 | 223 | if value is None: 224 | continue 225 | 226 | param_type = spec.get("type", "str") 227 | 228 | # Type conversion 229 | try: 230 | if param_type == "int": 231 | value = int(value) 232 | elif param_type == "float": 233 | value = float(value) 234 | elif param_type == "bool": 235 | value = bool(value) 236 | elif param_type == "str": 237 | value = str(value) 238 | except (ValueError, TypeError): 239 | print(f"Warning: Could not convert {param_name} to {param_type}, using default") 240 | value = spec.get("default") 241 | 242 | # Range validation 243 | if "min" in spec and value < spec["min"]: 244 | value = spec["min"] 245 | if "max" in spec and value > spec["max"]: 246 | value = spec["max"] 247 | 248 | # Options validation 249 | if "options" in spec and value not in spec["options"]: 250 | value = spec.get("default", spec["options"][0]) 251 | 252 | validated[param_name] = value 253 | 254 | return validated 255 | 256 | def create_fallback_melody(length_bars: int = 4, 257 | scale_root: int = 60, 258 | scale_type: str = "major") -> List[pretty_midi.Note]: 259 | """ 260 | Create a simple fallback melody for when API calls fail 261 | 262 | Args: 263 | length_bars: Number of bars to generate 264 | scale_root: Root note of the scale 265 | scale_type: Type of scale to use 266 | 267 | Returns: 268 | List of pretty_midi.Note objects 269 | """ 270 | import random 271 | 272 | # Define scales 273 | scales = { 274 | "major": [0, 2, 4, 5, 7, 9, 11], 275 | "minor": [0, 2, 3, 5, 7, 8, 10], 276 | "pentatonic": [0, 2, 4, 7, 9], 277 | "blues": [0, 3, 5, 6, 7, 10] 278 | } 279 | 280 | intervals = scales.get(scale_type, scales["major"]) 281 | scale_notes = [scale_root + interval for interval in intervals] 282 | scale_notes.extend([scale_root + 12 + interval for interval in intervals]) # Add octave 283 | 284 | notes = [] 285 | beats_per_bar = 4 286 | note_duration = 0.5 # Eighth notes 287 | current_time = 0.0 288 | 289 | total_notes = length_bars * beats_per_bar * 2 # 2 eighth notes per beat 290 | 291 | for i in range(total_notes): 292 | if random.random() > 0.15: # 85% chance of playing a note 293 | pitch = random.choice(scale_notes) 294 | velocity = random.randint(60, 100) 295 | duration = note_duration * random.uniform(0.8, 1.0) 296 | 297 | note = pretty_midi.Note( 298 | velocity=velocity, 299 | pitch=pitch, 300 | start=current_time, 301 | end=current_time + duration 302 | ) 303 | notes.append(note) 304 | 305 | current_time += note_duration 306 | 307 | return notes -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "reportAttributeAccessIssue": false 3 | } -------------------------------------------------------------------------------- /pythonpackages.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/pythonpackages.zip -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # ====================================== 2 | # MIDI Generator Piano Roll Project Requirements 3 | # ====================================== 4 | 5 | # GUI Framework 6 | PySide6==6.6.0 7 | 8 | # MIDI Processing 9 | pretty_midi==0.2.10 10 | mido==1.3.0 11 | python-rtmidi==1.5.8 # ⬅️ updated to working version 12 | 13 | # Audio and MIDI Playback 14 | pygame==2.5.2 15 | pyfluidsynth==1.3.2 16 | 17 | # Numerical and Scientific Computing 18 | numpy==1.24.3 19 | scikit-learn==1.3.2 20 | matplotlib==3.8.0 21 | tqdm==4.66.1 22 | einx==0.3.0 23 | einops==0.7.0 24 | psutil 25 | 26 | # API and Web Integration 27 | gradio_client==1.10.2 28 | requests==2.31.0 29 | 30 | # Note: PyTorch is installed separately during the installation process 31 | # to allow for CPU/GPU selection 32 | -------------------------------------------------------------------------------- /soundbank/soundfont.sf2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebChatAppAi/midi-gen/999e53e964b43b548181f85000e8992b09190977/soundbank/soundfont.sf2 -------------------------------------------------------------------------------- /start.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | 4 | REM =========================================== 5 | REM 🟢 PianoRollStudio - App Starter (Embedded) 6 | REM =========================================== 7 | 8 | echo 📦 Using embedded Python: pythonpackages\python.exe 9 | 10 | if not exist "pythonpackages\python.exe" ( 11 | echo ❌ Embedded Python not found in pythonpackages\ 12 | echo Please make sure python.exe is inside that folder. 13 | pause 14 | exit /b 1 15 | ) 16 | 17 | echo 🚀 Launching app.py using embedded Python... 18 | pythonpackages\python.exe app.py 19 | 20 | echo 🛑 Application closed. 21 | pause 22 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # =========================================== 4 | # 🎹 MIDI-GEN - Launcher (Unix) 5 | # =========================================== 6 | 7 | echo "🎹 Starting MIDI-GEN..." 8 | 9 | PYTHON_DIR="pythonpackages" 10 | 11 | # Check if pythonpackages environment exists 12 | if [ ! -d "$PYTHON_DIR" ]; then 13 | echo "❌ Python environment not found: $PYTHON_DIR" 14 | echo "Please run ./install.sh first to set up the environment." 15 | echo "For troubleshooting, check installations.md" 16 | exit 1 17 | fi 18 | 19 | if [ ! -f "$PYTHON_DIR/bin/python" ]; then 20 | echo "❌ Python executable not found: $PYTHON_DIR/bin/python" 21 | echo "The environment appears to be corrupted. Please run ./install.sh to reinstall." 22 | echo "For troubleshooting, check installations.md" 23 | exit 1 24 | fi 25 | 26 | # Activate the pythonpackages environment 27 | echo "🔄 Activating Python environment from $PYTHON_DIR..." 28 | source "$PYTHON_DIR/bin/activate" 29 | 30 | if [ $? -ne 0 ]; then 31 | echo "❌ Failed to activate Python environment." 32 | echo "The environment may be corrupted. Please run ./install.sh to reinstall." 33 | echo "For troubleshooting, check installations.md" 34 | exit 1 35 | fi 36 | 37 | echo "✅ Python environment activated successfully" 38 | 39 | # Check if app.py exists 40 | if [ ! -f "app.py" ]; then 41 | echo "❌ app.py not found in current directory" 42 | echo "Please make sure you're running this script from the project root" 43 | echo "For troubleshooting, check installations.md" 44 | exit 1 45 | fi 46 | 47 | echo "🚀 Launching MIDI-GEN application..." 48 | 49 | # Run the main application 50 | python app.py 51 | 52 | # Check exit code 53 | if [ $? -ne 0 ]; then 54 | echo "❌ Application terminated with an error. Check the log above." 55 | echo "For troubleshooting, check installations.md" 56 | echo "Press Enter to exit..." 57 | read 58 | exit 1 59 | fi 60 | 61 | echo "👋 Application closed successfully. Press Enter to exit..." 62 | read 63 | -------------------------------------------------------------------------------- /ui/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the 'ui' directory a Python package. 2 | -------------------------------------------------------------------------------- /ui/dock_area_widget.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dock Area Widget Module 3 | 4 | Provides dedicated dock detection zones that remain accessible regardless 5 | of the piano roll state, ensuring consistent docking behavior for floating panels. 6 | """ 7 | 8 | from PySide6.QtWidgets import QWidget 9 | from PySide6.QtCore import Qt, QSize 10 | from PySide6.QtGui import QPainter, QDragEnterEvent, QDragMoveEvent, QDropEvent 11 | 12 | try: 13 | from config import theme 14 | except ImportError: 15 | try: 16 | from ..config import theme 17 | except ImportError as e: 18 | print(f"Failed to import theme config in dock_area_widget: {e}") 19 | 20 | 21 | class DockAreaWidget(QWidget): 22 | """ 23 | A thin, dedicated widget that provides consistent dock detection zones 24 | for Qt's docking system, ensuring floating panels can always be reattached 25 | regardless of the piano roll's state. 26 | """ 27 | 28 | def __init__(self, edge_position="left", parent=None): 29 | """ 30 | Initialize the dock area widget. 31 | 32 | Args: 33 | edge_position (str): Either "left" or "right" to specify which edge this widget covers 34 | parent: Parent widget 35 | """ 36 | super().__init__(parent) 37 | self.edge_position = edge_position 38 | 39 | # Make the widget extremely thin but tall enough to capture dock events 40 | self.setFixedWidth(3) # Very thin - just enough for dock detection 41 | 42 | # Set minimum height to ensure it's always visible and stretches full height 43 | self.setMinimumHeight(200) 44 | 45 | # Set size policy to ensure it takes full height but stays thin 46 | from PySide6.QtWidgets import QSizePolicy 47 | size_policy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) 48 | self.setSizePolicy(size_policy) 49 | 50 | # Configure widget properties for dock detection 51 | self.setAcceptDrops(True) 52 | self.setAttribute(Qt.WA_TransparentForMouseEvents, False) # Must accept mouse events for docking 53 | self.setAttribute(Qt.WA_OpaquePaintEvent, False) # Allow transparency 54 | 55 | # Set tool tip for debugging 56 | self.setToolTip(f"Dock Area Zone ({edge_position.title()})") 57 | 58 | # Make it invisible but functional 59 | self.setStyleSheet(""" 60 | QWidget { 61 | background-color: transparent; 62 | border: none; 63 | } 64 | """) 65 | 66 | def sizeHint(self): 67 | """Provide size hint for the layout system.""" 68 | return QSize(3, 200) 69 | 70 | def minimumSizeHint(self): 71 | """Provide minimum size hint.""" 72 | return QSize(3, 200) 73 | 74 | def paintEvent(self, event): 75 | """ 76 | Paint the widget. Normally invisible, but can be made visible for debugging. 77 | """ 78 | painter = QPainter(self) 79 | 80 | # For debugging purposes, uncomment the line below to make the dock zones visible 81 | # painter.fillRect(self.rect(), QColor(255, 0, 0, 50)) # Semi-transparent red 82 | 83 | # Normally, draw nothing (transparent) 84 | painter.fillRect(self.rect(), Qt.transparent) 85 | 86 | def dragEnterEvent(self, event: QDragEnterEvent): 87 | """ 88 | Handle drag enter events - accept dock-related drags. 89 | """ 90 | # Accept all drag events to ensure dock detection works 91 | event.acceptProposedAction() 92 | super().dragEnterEvent(event) 93 | 94 | def dragMoveEvent(self, event: QDragMoveEvent): 95 | """ 96 | Handle drag move events during docking operations. 97 | """ 98 | event.acceptProposedAction() 99 | super().dragMoveEvent(event) 100 | 101 | def dropEvent(self, event: QDropEvent): 102 | """ 103 | Handle drop events - let the main window handle the actual docking. 104 | """ 105 | # Don't handle the drop ourselves - let Qt's docking system handle it 106 | event.ignore() 107 | super().dropEvent(event) 108 | 109 | def mousePressEvent(self, event): 110 | """ 111 | Handle mouse press events - pass through to parent for docking. 112 | """ 113 | # Pass through to parent widget to ensure dock detection works 114 | event.ignore() 115 | super().mousePressEvent(event) 116 | 117 | def mouseMoveEvent(self, event): 118 | """ 119 | Handle mouse move events - pass through to parent for docking. 120 | """ 121 | # Pass through to parent widget to ensure dock detection works 122 | event.ignore() 123 | super().mouseMoveEvent(event) 124 | 125 | def enable_debug_visual(self, enable=True): 126 | """ 127 | Enable or disable visual debugging of the dock area. 128 | 129 | Args: 130 | enable (bool): Whether to show the dock area visually 131 | """ 132 | if enable: 133 | self.setStyleSheet(""" 134 | QWidget { 135 | background-color: rgba(255, 0, 0, 50); 136 | border: 1px solid red; 137 | } 138 | """) 139 | else: 140 | self.setStyleSheet(""" 141 | QWidget { 142 | background-color: transparent; 143 | border: none; 144 | } 145 | """) 146 | self.update() 147 | 148 | 149 | class DockAreaManager: 150 | """ 151 | Manager class to coordinate multiple dock area widgets and integrate 152 | them with the main window layout. 153 | """ 154 | 155 | def __init__(self, main_window): 156 | """ 157 | Initialize the dock area manager. 158 | 159 | Args: 160 | main_window: The main window instance to integrate dock areas with 161 | """ 162 | self.main_window = main_window 163 | self.left_dock_area = None 164 | self.right_dock_area = None 165 | 166 | def setup_dock_areas(self, parent_layout): 167 | """ 168 | Set up left and right dock area widgets in the provided layout. 169 | 170 | Args: 171 | parent_layout: The layout to add dock areas to (typically QHBoxLayout) 172 | """ 173 | # Create left dock area 174 | self.left_dock_area = DockAreaWidget("left", self.main_window) 175 | parent_layout.insertWidget(0, self.left_dock_area) # Insert at beginning 176 | 177 | # Create right dock area 178 | self.right_dock_area = DockAreaWidget("right", self.main_window) 179 | parent_layout.addWidget(self.right_dock_area) # Add at end 180 | 181 | # Ensure the dock areas have proper properties for Qt's docking system 182 | for dock_area in [self.left_dock_area, self.right_dock_area]: 183 | dock_area.setAcceptDrops(True) 184 | dock_area.raise_() # Bring to front for better detection 185 | 186 | print("Dock area widgets created and integrated into layout") 187 | 188 | def enable_debug_visuals(self, enable=True): 189 | """ 190 | Enable or disable visual debugging for all dock areas. 191 | 192 | Args: 193 | enable (bool): Whether to show dock areas visually 194 | """ 195 | if self.left_dock_area: 196 | self.left_dock_area.enable_debug_visual(enable) 197 | if self.right_dock_area: 198 | self.right_dock_area.enable_debug_visual(enable) 199 | 200 | def get_dock_areas(self): 201 | """ 202 | Get references to the dock area widgets. 203 | 204 | Returns: 205 | tuple: (left_dock_area, right_dock_area) 206 | """ 207 | return (self.left_dock_area, self.right_dock_area) -------------------------------------------------------------------------------- /ui/drawing_utils.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt, QRect, QPoint, QRectF 2 | from PySide6.QtGui import QColor, QPen, QBrush, QLinearGradient, QFont, QRadialGradient, QFontMetrics 3 | import pretty_midi 4 | 5 | from config.constants import ( 6 | MIN_PITCH, MAX_PITCH, WHITE_KEY_WIDTH, BLACK_KEY_WIDTH, 7 | WHITE_KEY_HEIGHT, BLACK_KEY_HEIGHT, 8 | MIN_LABEL_PITCH, MAX_LABEL_PITCH 9 | ) 10 | from config import theme # Updated to import the whole module 11 | 12 | # Assuming these are defined in theme.py (if not, they will be added later) 13 | # For now, using fallbacks if specific names are not yet in the imported theme object. 14 | # PIANO_KEY_WHITE_COLOR = getattr(theme, 'PIANO_KEY_WHITE_COLOR', theme.WHITE_KEY_COLOR) # Already exists 15 | # PIANO_KEY_BLACK_COLOR = getattr(theme, 'PIANO_KEY_BLACK_COLOR', theme.BLACK_KEY_COLOR) # Already exists 16 | # PIANO_KEY_BORDER_COLOR = getattr(theme, 'PIANO_KEY_BORDER_COLOR', theme.KEY_BORDER_COLOR) # Already exists 17 | # PIANO_KEY_LABEL_COLOR = getattr(theme, 'PIANO_KEY_LABEL_COLOR', theme.PRIMARY_TEXT_COLOR.darker(150)) # Example fallback 18 | # PIANO_KEY_BLACK_LABEL_COLOR = getattr(theme, 'PIANO_KEY_BLACK_LABEL_COLOR', theme.PRIMARY_TEXT_COLOR.lighter(150)) # Example fallback 19 | # NOTE_LABEL_COLOR = getattr(theme, 'NOTE_LABEL_COLOR', QColor(0,0,0,180)) # Example fallback 20 | # PLAYHEAD_TRIANGLE_COLOR = getattr(theme, 'PLAYHEAD_TRIANGLE_COLOR', theme.PLAYHEAD_COLOR) # Example fallback 21 | # PIANO_KEY_SEPARATOR_COLOR = getattr(theme, 'PIANO_KEY_SEPARATOR_COLOR', theme.BORDER_COLOR_NORMAL) # Example fallback 22 | 23 | 24 | def draw_time_grid(painter, width, height, time_scale, bpm, time_signature_numerator, time_signature_denominator, parent_widget, vertical_zoom_factor=1.0): 25 | """Draw the time grid with beats and measures and horizontal note lines""" 26 | keyboard_width = WHITE_KEY_WIDTH # This constant is from config.constants, not theme 27 | 28 | effective_white_key_height = WHITE_KEY_HEIGHT * vertical_zoom_factor # WHITE_KEY_HEIGHT from config.constants 29 | 30 | current_viewport_y_offset = 0 31 | if parent_widget and hasattr(parent_widget, 'verticalScrollBar'): 32 | current_viewport_y_offset = parent_widget.verticalScrollBar().value() 33 | elif parent_widget and hasattr(parent_widget, 'parentWidget') and parent_widget.parentWidget(): 34 | grandparent_obj = parent_widget.parentWidget() 35 | if grandparent_obj and hasattr(grandparent_obj, 'verticalScrollBar'): 36 | current_viewport_y_offset = grandparent_obj.verticalScrollBar().value() 37 | 38 | # Draw horizontal lines for note rows 39 | painter.setPen(QPen(theme.KEY_GRID_LINE_COLOR, 0.8, Qt.PenStyle.SolidLine)) # Use new name, subtle width 40 | for pitch in range(MIN_PITCH, MAX_PITCH + 1): 41 | y_pos = (MAX_PITCH - pitch) * effective_white_key_height 42 | painter.drawLine(keyboard_width, int(y_pos), width, int(y_pos)) 43 | note_name = pretty_midi.note_number_to_name(pitch) 44 | if note_name.endswith('C') and '#' not in note_name: 45 | highlight_rect = QRect(keyboard_width, int(y_pos), width - keyboard_width, int(effective_white_key_height)) 46 | painter.fillRect(highlight_rect, theme.GRID_ROW_HIGHLIGHT_COLOR) 47 | 48 | # Time signature display 49 | ts_text = f"{time_signature_numerator}/{time_signature_denominator}" 50 | painter.setPen(QPen(theme.SECONDARY_TEXT_COLOR, 1.0)) 51 | painter.setFont(QFont(theme.FONT_FAMILY_PRIMARY, theme.FONT_SIZE_S, weight=theme.FONT_WEIGHT_BOLD)) 52 | ts_x_pos = keyboard_width + theme.PADDING_S 53 | ts_y_pos = current_viewport_y_offset + theme.PADDING_M + theme.FONT_SIZE_S # Adjusted for font size 54 | painter.drawText(ts_x_pos, ts_y_pos, ts_text) 55 | 56 | actual_pixels_per_quarter_note = 0 57 | if bpm > 0: 58 | seconds_per_quarter_note = 60.0 / bpm 59 | actual_pixels_per_quarter_note = time_scale * seconds_per_quarter_note 60 | 61 | # Draw sixteenth note lines (very faint) 62 | painter.setPen(QPen(theme.GRID_LINE_COLOR, 0.5, Qt.PenStyle.DotLine)) # Subtle width 63 | sixteenth_note_step_pixels = actual_pixels_per_quarter_note / 4.0 if actual_pixels_per_quarter_note > 0 else 0 64 | if sixteenth_note_step_pixels > 5: # Only draw if lines are reasonably spaced 65 | for i in range(int(width / sixteenth_note_step_pixels) + 1): 66 | if i % 4 != 0: 67 | x = i * sixteenth_note_step_pixels + keyboard_width 68 | painter.drawLine(int(x), 0, int(x), height) 69 | 70 | pixels_per_beat = actual_pixels_per_quarter_note * (4.0 / time_signature_denominator) if time_signature_denominator > 0 else actual_pixels_per_quarter_note 71 | 72 | # Draw beat lines (more visible than grid, less than measure) 73 | if pixels_per_beat > 0: 74 | painter.setPen(QPen(theme.GRID_BEAT_LINE_COLOR, 0.7, Qt.PenStyle.SolidLine)) # Subtle width 75 | for i in range(int(width / pixels_per_beat) + 1): 76 | if i % time_signature_numerator != 0: 77 | x = i * pixels_per_beat + keyboard_width 78 | painter.drawLine(int(x), 0, int(x), height) 79 | 80 | # Draw measure lines (most prominent grid line) 81 | if pixels_per_beat > 0 and time_signature_numerator > 0: 82 | pixels_per_measure = pixels_per_beat * time_signature_numerator 83 | if pixels_per_measure > 0: 84 | painter.setPen(QPen(theme.GRID_MEASURE_LINE_COLOR, 1.0, Qt.PenStyle.SolidLine)) # Slightly more prominent 85 | measure_number_y_pos = current_viewport_y_offset + theme.PADDING_M + theme.FONT_SIZE_S 86 | for i in range(int(width / pixels_per_measure) + 1): 87 | x = i * pixels_per_measure + keyboard_width 88 | painter.drawLine(int(x), 0, int(x), height) 89 | painter.setPen(QPen(theme.SECONDARY_TEXT_COLOR, 1.0)) 90 | painter.setFont(QFont(theme.FONT_FAMILY_PRIMARY, theme.FONT_SIZE_XS)) 91 | painter.drawText(int(x + theme.PADDING_XS), measure_number_y_pos, str(i + 1)) 92 | 93 | # Draw keyboard separator line 94 | # Using BORDER_COLOR_NORMAL for a standard separator 95 | piano_key_separator_color = getattr(theme, 'PIANO_KEY_SEPARATOR_COLOR', theme.BORDER_COLOR_NORMAL) 96 | painter.setPen(QPen(piano_key_separator_color, 1.0)) 97 | painter.drawLine(keyboard_width, 0, keyboard_width, height) 98 | 99 | def draw_piano_keys(painter, vertical_zoom_factor=1.0): 100 | """Draw the piano keyboard on the left side""" 101 | effective_white_key_height = WHITE_KEY_HEIGHT * vertical_zoom_factor 102 | effective_black_key_height = BLACK_KEY_HEIGHT * vertical_zoom_factor 103 | drawn_black_keys = set() 104 | 105 | # Define label colors (assuming these will be added to theme.py) 106 | piano_key_label_color = getattr(theme, 'PIANO_KEY_LABEL_COLOR', theme.PRIMARY_TEXT_COLOR.darker(180)) # Darker text for light keys 107 | piano_key_black_label_color = getattr(theme, 'PIANO_KEY_BLACK_LABEL_COLOR', theme.PRIMARY_TEXT_COLOR.lighter(180)) # Lighter text for dark keys 108 | 109 | # Draw white keys first 110 | for pitch in range(MIN_PITCH, MAX_PITCH + 1): 111 | pitch_class = pitch % 12 112 | is_white = pitch_class in [0, 2, 4, 5, 7, 9, 11] 113 | if is_white: 114 | y_pos_key_top = (MAX_PITCH - pitch) * effective_white_key_height 115 | key_rect = QRect(0, int(y_pos_key_top), WHITE_KEY_WIDTH, int(effective_white_key_height)) 116 | 117 | base_color = theme.WHITE_KEY_COLOR # New theme constant 118 | gradient = QLinearGradient(key_rect.topLeft(), key_rect.bottomLeft()) 119 | gradient.setColorAt(0.0, base_color.lighter(105)) 120 | gradient.setColorAt(1.0, base_color.darker(102)) 121 | painter.fillRect(key_rect, gradient) 122 | painter.setPen(QPen(theme.KEY_BORDER_COLOR, 0.5)) # Use new theme constant, thin border 123 | painter.drawRect(key_rect) 124 | 125 | # Draw black keys on top 126 | for pitch in range(MIN_PITCH, MAX_PITCH + 1): 127 | pitch_class = pitch % 12 128 | is_black = pitch_class in [1, 3, 6, 8, 10] 129 | if is_black: 130 | if pitch in drawn_black_keys: continue 131 | drawn_black_keys.add(pitch) 132 | y_pos_key_top = (MAX_PITCH - pitch) * effective_white_key_height 133 | key_rect = QRect(0, int(y_pos_key_top), BLACK_KEY_WIDTH, int(effective_black_key_height)) 134 | 135 | base_color = theme.BLACK_KEY_COLOR # New theme constant 136 | gradient = QLinearGradient(key_rect.topLeft(), key_rect.bottomLeft()) 137 | gradient.setColorAt(0.0, base_color.lighter(115)) # Black keys get less intense gradient 138 | gradient.setColorAt(1.0, base_color) 139 | painter.fillRect(key_rect, gradient) 140 | painter.setPen(QPen(theme.KEY_BORDER_COLOR, 0.5)) # Use new theme constant 141 | painter.drawRect(key_rect) 142 | 143 | # Draw labels on keys with bold, more visible font 144 | label_font = QFont(theme.FONT_FAMILY_PRIMARY, theme.FONT_SIZE_M, weight=theme.FONT_WEIGHT_BOLD) # Bigger, bolder font 145 | painter.setFont(label_font) 146 | metrics = QFontMetrics(label_font) 147 | 148 | for pitch_label in range(MIN_LABEL_PITCH, MAX_LABEL_PITCH + 1): 149 | if pitch_label < MIN_PITCH or pitch_label > MAX_PITCH: continue 150 | pitch_class = pitch_label % 12 151 | is_white_key_for_label = pitch_class in [0, 2, 4, 5, 7, 9, 11] 152 | 153 | # Use the original note name directly - pretty_midi already gives correct octave numbers 154 | # C0 = MIDI 12, C1 = MIDI 24, C2 = MIDI 36, C3 = MIDI 48, C4 = MIDI 60 (Middle C), etc. 155 | corrected_label_name = pretty_midi.note_number_to_name(pitch_label) 156 | 157 | key_slot_y_top = (MAX_PITCH - pitch_label) * effective_white_key_height 158 | 159 | if is_white_key_for_label: 160 | text_rect = QRect(0, int(key_slot_y_top), WHITE_KEY_WIDTH, int(effective_white_key_height)) 161 | painter.setPen(piano_key_label_color) 162 | painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter, corrected_label_name) 163 | else: 164 | if pitch_label in drawn_black_keys: # Ensure label is only for drawn keys 165 | black_key_rect = QRect(0, int(key_slot_y_top), BLACK_KEY_WIDTH, int(effective_black_key_height)) 166 | painter.setPen(piano_key_black_label_color) 167 | painter.drawText(black_key_rect, Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter, corrected_label_name) 168 | 169 | def draw_notes(painter, notes, time_scale, vertical_zoom_factor=1.0): 170 | """Draw the MIDI notes as colored rectangles""" 171 | effective_white_key_height = WHITE_KEY_HEIGHT * vertical_zoom_factor 172 | effective_black_key_height = BLACK_KEY_HEIGHT * vertical_zoom_factor 173 | note_label_color = getattr(theme, 'NOTE_LABEL_COLOR', QColor(0,0,0,180)) # Fallback 174 | 175 | for note in notes: 176 | if not hasattr(note, 'pitch') or not hasattr(note, 'start') or not hasattr(note, 'end'): continue 177 | pitch = note.pitch 178 | if pitch < MIN_PITCH or pitch > MAX_PITCH: continue 179 | 180 | y_pos = (MAX_PITCH - pitch) * effective_white_key_height 181 | x_pos = note.start * time_scale + WHITE_KEY_WIDTH 182 | width = max((note.end - note.start) * time_scale, 4) 183 | pitch_class = pitch % 12 184 | is_white = pitch_class in [0, 2, 4, 5, 7, 9, 11] 185 | padding = 4 186 | 187 | if is_white: 188 | height = effective_white_key_height - padding 189 | y_offset = padding / 2 190 | else: 191 | height = effective_black_key_height - padding 192 | y_offset = (effective_white_key_height - effective_black_key_height) / 2 + (padding / 2) 193 | 194 | velocity = getattr(note, 'velocity', 64) 195 | # Use new theme note colors 196 | if velocity < 50: color = theme.NOTE_LOW_COLOR 197 | elif velocity < 90: color = theme.NOTE_MED_COLOR 198 | else: color = theme.NOTE_HIGH_COLOR 199 | 200 | gradient = QLinearGradient(x_pos, y_pos + y_offset, x_pos, y_pos + y_offset + height) 201 | gradient.setColorAt(0, color.lighter(130)) 202 | gradient.setColorAt(0.5, color) 203 | gradient.setColorAt(1, color.darker(110)) 204 | 205 | painter.setPen(QPen(theme.NOTE_BORDER_COLOR, 0.5)) # Use new theme constant, subtle 206 | painter.setBrush(QBrush(gradient)) 207 | painter.drawRoundedRect(int(x_pos), int(y_pos + y_offset), int(width), int(height), 208 | theme.BORDER_RADIUS_S, theme.BORDER_RADIUS_S) # Use theme radius 209 | 210 | # Note labels - make them bold and visible 211 | corrected_label_name_note = pretty_midi.note_number_to_name(pitch) 212 | 213 | note_block_font = QFont(theme.FONT_FAMILY_PRIMARY, theme.FONT_SIZE_S, weight=theme.FONT_WEIGHT_BOLD) # Bigger, bolder font 214 | painter.setFont(note_block_font) 215 | 216 | # Use high contrast color for note labels - white with some transparency for visibility 217 | note_label_color = QColor(255, 255, 255, 220) # Bright white with high opacity 218 | painter.setPen(QPen(note_label_color)) 219 | 220 | font_metrics = QFontMetrics(painter.font()) 221 | text_width_needed = font_metrics.horizontalAdvance(corrected_label_name_note) 222 | text_height_needed = font_metrics.height() 223 | note_content_rect = QRectF(x_pos, y_pos + y_offset, width, height) 224 | 225 | # More relaxed condition for showing text - show if note is reasonably sized 226 | if width >= 25 and height >= 12: # Simple size check instead of complex text fitting 227 | painter.drawText(note_content_rect, Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter, corrected_label_name_note) 228 | 229 | def draw_playhead(painter, playhead_position, time_scale, height): 230 | """Draw the playhead indicator with shadow""" 231 | if playhead_position >= 0: # Allow drawing at position 0 232 | playhead_x = playhead_position * time_scale + WHITE_KEY_WIDTH 233 | shadow_offset_x = 2 # Increased offset for better visibility 234 | shadow_offset_y = 2 # Increased offset for better visibility 235 | triangle_size = theme.ICON_SIZE_S // 2 # Base size on theme icon size 236 | triangle_height = int(triangle_size * 1.5) 237 | 238 | # --- Draw Shadow --- 239 | painter.save() 240 | shadow_pen = QPen(theme.PLAYHEAD_SHADOW_COLOR, 2) 241 | painter.setPen(shadow_pen) 242 | painter.setBrush(QBrush(theme.PLAYHEAD_SHADOW_COLOR)) 243 | 244 | # Shadow for the line 245 | painter.drawLine(int(playhead_x + shadow_offset_x), shadow_offset_y, int(playhead_x + shadow_offset_x), height + shadow_offset_y) 246 | 247 | # Shadow for the triangle 248 | shadow_points = [ 249 | QPoint(int(playhead_x + shadow_offset_x), shadow_offset_y), 250 | QPoint(int(playhead_x - triangle_size + shadow_offset_x), triangle_height + shadow_offset_y), 251 | QPoint(int(playhead_x + triangle_size + shadow_offset_x), triangle_height + shadow_offset_y) 252 | ] 253 | painter.drawPolygon(shadow_points) 254 | painter.restore() 255 | 256 | # --- Draw Main Playhead --- 257 | # Main line 258 | playhead_pen = QPen(theme.PLAYHEAD_COLOR, 2) # Use theme color, 2px width 259 | painter.setPen(playhead_pen) 260 | painter.drawLine(int(playhead_x), 0, int(playhead_x), height) 261 | 262 | # Triangle marker 263 | # playhead_triangle_color is already defined in theme.py and should be used directly 264 | painter.setBrush(QBrush(theme.PLAYHEAD_TRIANGLE_COLOR)) # Solid color 265 | painter.setPen(Qt.PenStyle.NoPen) # No border for triangle for cleaner look 266 | 267 | points = [ 268 | QPoint(int(playhead_x), 0), 269 | QPoint(int(playhead_x - triangle_size), triangle_height), 270 | QPoint(int(playhead_x + triangle_size), triangle_height) 271 | ] 272 | painter.drawPolygon(points) -------------------------------------------------------------------------------- /ui/event_handlers.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import Qt, QEvent, QObject 2 | from PySide6.QtGui import QKeyEvent 3 | from PySide6.QtWidgets import QApplication, QLineEdit, QTextEdit, QSpinBox, QDoubleSpinBox, QComboBox 4 | 5 | class MainWindowEventHandlersMixin: 6 | """ 7 | Mixin class to handle specific events for the main window, 8 | such as keyboard shortcuts and close events. 9 | This event filter is installed on the MainWindow itself. 10 | """ 11 | 12 | def eventFilter(self, watched, event): 13 | """Event filter for MainWindow - spacebar handling is now done in the main window class.""" 14 | # Spacebar handling moved to PianoRollMainWindow.eventFilter() to avoid conflicts 15 | # This mixin eventFilter is kept for other potential shortcuts in the future 16 | return super().eventFilter(watched, event) 17 | 18 | def closeEvent(self, event): 19 | """Clean up resources when window is closed.""" 20 | if hasattr(self, 'midi_player') and self.midi_player: 21 | self.midi_player.stop() 22 | if hasattr(self, 'playback_timer') and self.playback_timer: 23 | self.playback_timer.stop() 24 | 25 | if hasattr(self, 'plugin_manager_panel') and self.plugin_manager_panel and \ 26 | hasattr(self.plugin_manager_panel, 'cleanup_temporary_files') and \ 27 | callable(self.plugin_manager_panel.cleanup_temporary_files): 28 | print("MainWindow: Cleaning up temporary MIDI files...") 29 | self.plugin_manager_panel.cleanup_temporary_files() 30 | 31 | super().closeEvent(event) 32 | 33 | class GlobalPlaybackHotkeyFilter(QObject): 34 | """ 35 | Global event filter to handle Spacebar for toggling playback. 36 | Installed on QApplication.instance(). 37 | """ 38 | def __init__(self, main_window_toggle_method, parent=None): 39 | super().__init__(parent) 40 | self.main_window_toggle_method = main_window_toggle_method 41 | 42 | def eventFilter(self, watched, event): 43 | if event.type() == QEvent.KeyPress: 44 | if isinstance(event, QKeyEvent) and event.key() == Qt.Key_Space: 45 | focused_widget = QApplication.focusWidget() 46 | 47 | print(f"🎹 GlobalFilter: Spacebar pressed! Focused widget: {focused_widget.__class__.__name__ if focused_widget else 'None'}") 48 | 49 | # Don't handle spacebar if user is typing in text fields 50 | if isinstance(focused_widget, (QLineEdit, QTextEdit, QSpinBox, QDoubleSpinBox)): 51 | print("❌ GlobalFilter: Spacebar ignored - user is typing in text input field") 52 | return False 53 | 54 | if isinstance(focused_widget, QComboBox) and focused_widget.view().isVisible(): 55 | print("❌ GlobalFilter: Spacebar ignored - ComboBox dropdown is open") 56 | return False 57 | 58 | # Handle spacebar for playback 59 | if callable(self.main_window_toggle_method): 60 | print("✅ GlobalFilter: Spacebar handled - calling toggle_playback!") 61 | try: 62 | self.main_window_toggle_method() 63 | return True # Event consumed 64 | except Exception as e: 65 | print(f"❌ GlobalFilter: Error calling toggle_playback: {e}") 66 | return False 67 | else: 68 | print("❌ GlobalFilter: toggle_playback method not callable!") 69 | 70 | return False # Don't consume other events 71 | -------------------------------------------------------------------------------- /ui/model_downloader/__init__.py: -------------------------------------------------------------------------------- 1 | # Model Downloader Package 2 | # This package handles AI model downloading, management, and related operations 3 | 4 | from .downloader_dialog import ModelDownloaderDialog 5 | 6 | __all__ = ['ModelDownloaderDialog'] -------------------------------------------------------------------------------- /ui/model_downloader/download_manager.py: -------------------------------------------------------------------------------- 1 | # Download Manager Module 2 | # This module will handle the actual downloading of AI models 3 | 4 | import os 5 | import sys 6 | from pathlib import Path 7 | from PySide6.QtCore import QObject, Signal, QThread 8 | 9 | class DownloadManager(QObject): 10 | """ 11 | Manages downloading of AI models 12 | 13 | Future implementation will include: 14 | - HTTP/HTTPS downloads with progress tracking 15 | - Resume capability for interrupted downloads 16 | - Checksum validation 17 | - Automatic installation after download 18 | """ 19 | 20 | # Signals 21 | downloadStarted = Signal(str) # Emitted when download starts (model name) 22 | downloadProgress = Signal(str, int) # Emitted during download (model name, percentage) 23 | downloadCompleted = Signal(str, str) # Emitted when download completes (model name, file path) 24 | downloadFailed = Signal(str, str) # Emitted on error (model name, error message) 25 | 26 | def __init__(self): 27 | super().__init__() 28 | self.active_downloads = {} # Track active downloads 29 | 30 | def downloadModel(self, model_info, destination_path): 31 | """ 32 | Start downloading a model 33 | 34 | Args: 35 | model_info (dict): Model information including download URL 36 | destination_path (str): Where to save the downloaded file 37 | """ 38 | # Placeholder implementation 39 | model_name = model_info.get('name', 'Unknown Model') 40 | print(f"DownloadManager: Would download {model_name} to {destination_path}") 41 | 42 | # TODO: Implement actual download logic 43 | # - Create download worker thread 44 | # - Handle HTTP requests with progress tracking 45 | # - Validate downloads with checksums 46 | # - Move completed files to models directory 47 | pass 48 | 49 | def cancelDownload(self, model_name): 50 | """Cancel an active download""" 51 | # TODO: Implement download cancellation 52 | pass 53 | 54 | def pauseDownload(self, model_name): 55 | """Pause an active download""" 56 | # TODO: Implement download pausing 57 | pass 58 | 59 | def resumeDownload(self, model_name): 60 | """Resume a paused download""" 61 | # TODO: Implement download resuming 62 | pass 63 | 64 | class DownloadWorker(QThread): 65 | """ 66 | Worker thread for downloading models in the background 67 | 68 | Future implementation will include: 69 | - Non-blocking HTTP downloads 70 | - Progress reporting 71 | - Error handling and retry logic 72 | - Checksum validation 73 | """ 74 | 75 | # Signals 76 | progress = Signal(int) # Download progress percentage 77 | finished = Signal(str) # Download completed (file path) 78 | error = Signal(str) # Download error (error message) 79 | 80 | def __init__(self, model_info, destination_path): 81 | super().__init__() 82 | self.model_info = model_info 83 | self.destination_path = destination_path 84 | self.cancelled = False 85 | 86 | def run(self): 87 | """Run the download in background thread""" 88 | # TODO: Implement actual download logic 89 | # - Use requests or urllib for HTTP downloads 90 | # - Report progress via signals 91 | # - Handle errors gracefully 92 | # - Validate downloaded files 93 | pass 94 | 95 | def cancel(self): 96 | """Cancel the download""" 97 | self.cancelled = True -------------------------------------------------------------------------------- /ui/model_downloader/error_handler.py: -------------------------------------------------------------------------------- 1 | # Error Handler Module 2 | # This module will handle errors that occur during model downloads and management 3 | 4 | import os 5 | import sys 6 | import logging 7 | from enum import Enum 8 | from PySide6.QtWidgets import QMessageBox 9 | from PySide6.QtCore import QObject, Signal 10 | 11 | class DownloadError(Enum): 12 | """Enumeration of possible download errors""" 13 | NETWORK_ERROR = "network_error" 14 | FILE_SYSTEM_ERROR = "file_system_error" 15 | INVALID_URL = "invalid_url" 16 | INSUFFICIENT_SPACE = "insufficient_space" 17 | CHECKSUM_MISMATCH = "checksum_mismatch" 18 | PERMISSION_DENIED = "permission_denied" 19 | DOWNLOAD_CANCELLED = "download_cancelled" 20 | SERVER_ERROR = "server_error" 21 | TIMEOUT_ERROR = "timeout_error" 22 | UNKNOWN_ERROR = "unknown_error" 23 | 24 | class ErrorHandler(QObject): 25 | """ 26 | Handles errors that occur during model downloading and management 27 | 28 | Future implementation will include: 29 | - Comprehensive error classification 30 | - User-friendly error messages 31 | - Recovery suggestions 32 | - Logging and error reporting 33 | """ 34 | 35 | # Signals 36 | errorOccurred = Signal(str, str) # Emitted when an error occurs (error type, message) 37 | 38 | def __init__(self): 39 | super().__init__() 40 | self.setup_logging() 41 | 42 | def setup_logging(self): 43 | """Setup logging for error tracking""" 44 | # TODO: Implement proper logging setup 45 | # - Create log files in appropriate directory 46 | # - Set up rotating log files 47 | # - Configure log levels 48 | pass 49 | 50 | def handle_download_error(self, error_type, error_message, model_name=None): 51 | """ 52 | Handle download-related errors 53 | 54 | Args: 55 | error_type (DownloadError): Type of error that occurred 56 | error_message (str): Detailed error message 57 | model_name (str): Name of model that caused the error 58 | """ 59 | # TODO: Implement comprehensive error handling 60 | # - Log the error 61 | # - Display user-friendly message 62 | # - Suggest recovery actions 63 | # - Emit appropriate signals 64 | 65 | print(f"ErrorHandler: {error_type.value} - {error_message}") 66 | if model_name: 67 | print(f"ErrorHandler: Model affected: {model_name}") 68 | 69 | def handle_file_system_error(self, operation, file_path, error_message): 70 | """ 71 | Handle file system related errors 72 | 73 | Args: 74 | operation (str): Operation that failed (e.g., 'rename', 'delete', 'copy') 75 | file_path (str): Path of file that caused the error 76 | error_message (str): Detailed error message 77 | """ 78 | # TODO: Implement file system error handling 79 | # - Check permissions 80 | # - Suggest solutions 81 | # - Handle common scenarios 82 | pass 83 | 84 | def show_error_dialog(self, title, message, details=None): 85 | """ 86 | Show an error dialog to the user 87 | 88 | Args: 89 | title (str): Dialog title 90 | message (str): Main error message 91 | details (str): Optional detailed error information 92 | """ 93 | # TODO: Implement user-friendly error dialogs 94 | # - Create custom error dialog with recovery options 95 | # - Show detailed information in expandable section 96 | # - Provide buttons for common recovery actions 97 | 98 | msg_box = QMessageBox() 99 | msg_box.setIcon(QMessageBox.Critical) 100 | msg_box.setWindowTitle(title) 101 | msg_box.setText(message) 102 | 103 | if details: 104 | msg_box.setDetailedText(details) 105 | 106 | msg_box.exec() 107 | 108 | def get_recovery_suggestions(self, error_type): 109 | """ 110 | Get recovery suggestions for specific error types 111 | 112 | Args: 113 | error_type (DownloadError): Type of error 114 | 115 | Returns: 116 | list: List of suggested recovery actions 117 | """ 118 | # TODO: Implement recovery suggestions 119 | suggestions = { 120 | DownloadError.NETWORK_ERROR: [ 121 | "Check your internet connection", 122 | "Try downloading again", 123 | "Use a different network if available" 124 | ], 125 | DownloadError.INSUFFICIENT_SPACE: [ 126 | "Free up disk space", 127 | "Choose a different download location", 128 | "Delete unused model files" 129 | ], 130 | DownloadError.PERMISSION_DENIED: [ 131 | "Run the application as administrator", 132 | "Check folder permissions", 133 | "Choose a different download location" 134 | ] 135 | } 136 | 137 | return suggestions.get(error_type, ["Contact support for assistance"]) 138 | 139 | def log_error(self, error_type, error_message, model_name=None, stack_trace=None): 140 | """ 141 | Log error information for debugging 142 | 143 | Args: 144 | error_type (DownloadError): Type of error 145 | error_message (str): Error message 146 | model_name (str): Model name (if applicable) 147 | stack_trace (str): Stack trace (if available) 148 | """ 149 | # TODO: Implement comprehensive error logging 150 | # - Write to log files 151 | # - Include timestamp and context 152 | # - Implement log rotation 153 | pass -------------------------------------------------------------------------------- /ui/model_downloader/file_manager.py: -------------------------------------------------------------------------------- 1 | # File Manager Module 2 | # This module handles file operations for AI models (rename, delete, organize, etc.) 3 | 4 | import os 5 | import sys 6 | import shutil 7 | from pathlib import Path 8 | from PySide6.QtCore import QObject, Signal 9 | from PySide6.QtWidgets import QInputDialog, QMessageBox 10 | 11 | class ModelFileManager(QObject): 12 | """ 13 | Manages AI model files and operations 14 | 15 | Future implementation will include: 16 | - Safe file renaming with validation 17 | - Model file organization and categorization 18 | - Backup and restore functionality 19 | - Batch operations on multiple models 20 | """ 21 | 22 | # Signals 23 | fileRenamed = Signal(str, str) # Emitted when file is renamed (old_name, new_name) 24 | fileDeleted = Signal(str) # Emitted when file is deleted (file_name) 25 | fileMoved = Signal(str, str) # Emitted when file is moved (old_path, new_path) 26 | operationFailed = Signal(str, str) # Emitted on error (operation, error_message) 27 | 28 | def __init__(self): 29 | super().__init__() 30 | self.models_directory = self._get_models_directory() 31 | 32 | def _get_models_directory(self): 33 | """Get the models directory path""" 34 | if getattr(sys, 'frozen', False): 35 | # Running in a PyInstaller bundle 36 | base_dir = os.path.dirname(sys.executable) 37 | return os.path.join(base_dir, "ai_studio", "models") 38 | else: 39 | # Development mode 40 | return "ai_studio/models" 41 | 42 | def rename_model(self, old_name, new_name): 43 | """ 44 | Rename a model file 45 | 46 | Args: 47 | old_name (str): Current filename 48 | new_name (str): New filename 49 | 50 | Returns: 51 | bool: True if successful, False otherwise 52 | """ 53 | try: 54 | # Validate new name 55 | if not self._validate_filename(new_name): 56 | self.operationFailed.emit("rename", f"Invalid filename: {new_name}") 57 | return False 58 | 59 | # Ensure .pth extension 60 | if not new_name.endswith('.pth'): 61 | new_name += '.pth' 62 | 63 | old_path = os.path.join(self.models_directory, old_name) 64 | new_path = os.path.join(self.models_directory, new_name) 65 | 66 | # Check if old file exists 67 | if not os.path.exists(old_path): 68 | self.operationFailed.emit("rename", f"File not found: {old_name}") 69 | return False 70 | 71 | # Check if new name already exists 72 | if os.path.exists(new_path): 73 | self.operationFailed.emit("rename", f"File already exists: {new_name}") 74 | return False 75 | 76 | # Perform rename 77 | os.rename(old_path, new_path) 78 | self.fileRenamed.emit(old_name, new_name) 79 | return True 80 | 81 | except Exception as e: 82 | self.operationFailed.emit("rename", f"Error renaming file: {str(e)}") 83 | return False 84 | 85 | def delete_model(self, filename): 86 | """ 87 | Delete a model file 88 | 89 | Args: 90 | filename (str): Name of the file to delete 91 | 92 | Returns: 93 | bool: True if successful, False otherwise 94 | """ 95 | try: 96 | file_path = os.path.join(self.models_directory, filename) 97 | 98 | # Check if file exists 99 | if not os.path.exists(file_path): 100 | self.operationFailed.emit("delete", f"File not found: {filename}") 101 | return False 102 | 103 | # Delete the file 104 | os.remove(file_path) 105 | self.fileDeleted.emit(filename) 106 | return True 107 | 108 | except Exception as e: 109 | self.operationFailed.emit("delete", f"Error deleting file: {str(e)}") 110 | return False 111 | 112 | def move_model(self, filename, destination_directory): 113 | """ 114 | Move a model file to a different directory 115 | 116 | Args: 117 | filename (str): Name of the file to move 118 | destination_directory (str): Destination directory path 119 | 120 | Returns: 121 | bool: True if successful, False otherwise 122 | """ 123 | try: 124 | source_path = os.path.join(self.models_directory, filename) 125 | dest_path = os.path.join(destination_directory, filename) 126 | 127 | # Check if source file exists 128 | if not os.path.exists(source_path): 129 | self.operationFailed.emit("move", f"Source file not found: {filename}") 130 | return False 131 | 132 | # Create destination directory if it doesn't exist 133 | os.makedirs(destination_directory, exist_ok=True) 134 | 135 | # Check if destination file already exists 136 | if os.path.exists(dest_path): 137 | self.operationFailed.emit("move", f"Destination file already exists: {dest_path}") 138 | return False 139 | 140 | # Move the file 141 | shutil.move(source_path, dest_path) 142 | self.fileMoved.emit(source_path, dest_path) 143 | return True 144 | 145 | except Exception as e: 146 | self.operationFailed.emit("move", f"Error moving file: {str(e)}") 147 | return False 148 | 149 | def copy_model(self, filename, destination_directory, new_name=None): 150 | """ 151 | Copy a model file to a different location 152 | 153 | Args: 154 | filename (str): Name of the file to copy 155 | destination_directory (str): Destination directory path 156 | new_name (str): Optional new name for the copied file 157 | 158 | Returns: 159 | bool: True if successful, False otherwise 160 | """ 161 | try: 162 | source_path = os.path.join(self.models_directory, filename) 163 | dest_filename = new_name if new_name else filename 164 | dest_path = os.path.join(destination_directory, dest_filename) 165 | 166 | # Check if source file exists 167 | if not os.path.exists(source_path): 168 | self.operationFailed.emit("copy", f"Source file not found: {filename}") 169 | return False 170 | 171 | # Create destination directory if it doesn't exist 172 | os.makedirs(destination_directory, exist_ok=True) 173 | 174 | # Copy the file 175 | shutil.copy2(source_path, dest_path) 176 | return True 177 | 178 | except Exception as e: 179 | self.operationFailed.emit("copy", f"Error copying file: {str(e)}") 180 | return False 181 | 182 | def get_model_info(self, filename): 183 | """ 184 | Get detailed information about a model file 185 | 186 | Args: 187 | filename (str): Name of the model file 188 | 189 | Returns: 190 | dict: Dictionary containing file information 191 | """ 192 | try: 193 | file_path = os.path.join(self.models_directory, filename) 194 | 195 | if not os.path.exists(file_path): 196 | return None 197 | 198 | stat = os.stat(file_path) 199 | 200 | return { 201 | 'filename': filename, 202 | 'path': file_path, 203 | 'size_bytes': stat.st_size, 204 | 'size_mb': stat.st_size / (1024 * 1024), 205 | 'created_time': stat.st_ctime, 206 | 'modified_time': stat.st_mtime, 207 | 'accessed_time': stat.st_atime 208 | } 209 | 210 | except Exception as e: 211 | self.operationFailed.emit("info", f"Error getting file info: {str(e)}") 212 | return None 213 | 214 | def list_models(self): 215 | """ 216 | Get a list of all model files in the models directory 217 | 218 | Returns: 219 | list: List of model filenames 220 | """ 221 | try: 222 | if not os.path.exists(self.models_directory): 223 | return [] 224 | 225 | model_files = [] 226 | for file in os.listdir(self.models_directory): 227 | if file.endswith('.pth'): 228 | model_files.append(file) 229 | 230 | return sorted(model_files) 231 | 232 | except Exception as e: 233 | self.operationFailed.emit("list", f"Error listing models: {str(e)}") 234 | return [] 235 | 236 | def create_backup(self, filename, backup_directory=None): 237 | """ 238 | Create a backup of a model file 239 | 240 | Args: 241 | filename (str): Name of the file to backup 242 | backup_directory (str): Optional custom backup directory 243 | 244 | Returns: 245 | bool: True if successful, False otherwise 246 | """ 247 | try: 248 | if backup_directory is None: 249 | backup_directory = os.path.join(self.models_directory, "backups") 250 | 251 | # Create backup directory if it doesn't exist 252 | os.makedirs(backup_directory, exist_ok=True) 253 | 254 | # Generate backup filename with timestamp 255 | from datetime import datetime 256 | timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 257 | backup_filename = f"{os.path.splitext(filename)[0]}_{timestamp}.pth" 258 | 259 | return self.copy_model(filename, backup_directory, backup_filename) 260 | 261 | except Exception as e: 262 | self.operationFailed.emit("backup", f"Error creating backup: {str(e)}") 263 | return False 264 | 265 | def _validate_filename(self, filename): 266 | """ 267 | Validate a filename for safety and compatibility 268 | 269 | Args: 270 | filename (str): Filename to validate 271 | 272 | Returns: 273 | bool: True if valid, False otherwise 274 | """ 275 | # TODO: Implement comprehensive filename validation 276 | # - Check for invalid characters 277 | # - Ensure reasonable length 278 | # - Check for reserved names 279 | # - Validate extension 280 | 281 | if not filename: 282 | return False 283 | 284 | # Basic validation for now 285 | invalid_chars = '<>:"/\\|?*' 286 | for char in invalid_chars: 287 | if char in filename: 288 | return False 289 | 290 | return True 291 | 292 | def organize_models_by_category(self): 293 | """ 294 | Organize models into subdirectories by category 295 | 296 | Future implementation will: 297 | - Analyze model names for category hints 298 | - Create category subdirectories 299 | - Move models to appropriate categories 300 | - Maintain a category mapping file 301 | """ 302 | # TODO: Implement model organization by category 303 | pass 304 | 305 | def clean_temporary_files(self): 306 | """ 307 | Clean up temporary files and incomplete downloads 308 | 309 | Returns: 310 | int: Number of files cleaned up 311 | """ 312 | # TODO: Implement cleanup of temporary files 313 | # - Find .tmp files 314 | # - Find incomplete downloads 315 | # - Remove old backup files 316 | # - Clear cache files 317 | return 0 -------------------------------------------------------------------------------- /ui/model_downloader/models.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "Add new models here following the same structure. Set 'size' to 'TBD' to auto-fetch from server, or specify manually like '250 MB'", 3 | "_example": { 4 | "name": "Your Model Name", 5 | "description": "Description of what the model does", 6 | "size": "TBD", 7 | "version": "v1.0", 8 | "category": "Category", 9 | "download_url": "https://your-download-url.com/model.pth", 10 | "filename": "exact_filename_to_save.pth" 11 | }, 12 | "available_models": [ 13 | { 14 | "name": "POP909 Transformer", 15 | "description": "Specialized model for generating POP music compositions with modern pop arrangements and structures", 16 | "size": "TBD", 17 | "version": "v1.0", 18 | "category": "Pop", 19 | "download_url": "https://huggingface.co/projectlosangeles/MuseCraft/resolve/main/POP909_Transformer_Trained_Model_2531_steps_0.2682_loss_0.9298_acc.pth", 20 | "filename": "POP909_Transformer_Trained_Model_2531_steps_0.2682_loss_0.9298_acc.pth" 21 | }, 22 | { 23 | "name": "Piano Hands Transformer", 24 | "description": "Advanced model for generating classical music compositions with authentic piano hand coordination", 25 | "size": "TBD", 26 | "version": "v1.0", 27 | "category": "Classical", 28 | "download_url": "https://huggingface.co/projectlosangeles/MuseCraft/resolve/main/Piano_Hands_Transformer_Trained_Model_2942_steps_0.2362_loss_0.9315_acc.pth", 29 | "filename": "Piano_Hands_Transformer_Trained_Model_2942_steps_0.2362_loss_0.9315_acc.pth" 30 | }, 31 | { 32 | "name": "PiJAMA Jazz Transformer", 33 | "description": "Performance Jazz music model specialized in generating authentic jazz compositions with complex harmonies", 34 | "size": "TBD", 35 | "version": "v1.0", 36 | "category": "Jazz", 37 | "download_url": "https://huggingface.co/projectlosangeles/MuseCraft/resolve/main/PiJAMA_Transformer_Trained_Model_11229_steps_1.299_loss_0.6088_acc.pth", 38 | "filename": "PiJAMA_Transformer_Trained_Model_11229_steps_1.299_loss_0.6088_acc.pth" 39 | }, 40 | { 41 | "name": "Orchestral Composer", 42 | "description": "Multi-instrument orchestral composition with advanced harmonic understanding", 43 | "size": "500 MB", 44 | "version": "v3.0", 45 | "category": "Orchestral", 46 | "download_url": "https://example.com/orchestral_v3.pth" 47 | }, 48 | { 49 | "name": "Electronic Beat Maker", 50 | "description": "Modern electronic music patterns and synthetic melody generation", 51 | "size": "120 MB", 52 | "version": "v1.0", 53 | "category": "Electronic", 54 | "download_url": "https://example.com/electronic_v1.pth" 55 | }, 56 | { 57 | "name": "Folk Song Generator", 58 | "description": "Traditional folk melodies from various cultural backgrounds worldwide", 59 | "size": "95 MB", 60 | "version": "v2.0", 61 | "category": "Folk", 62 | "download_url": "https://example.com/folk_v2.pth" 63 | }, 64 | { 65 | "name": "Ambient Soundscape", 66 | "description": "Atmospheric ambient music generation for relaxation and meditation", 67 | "size": "75 MB", 68 | "version": "v1.5", 69 | "category": "Ambient", 70 | "download_url": "https://example.com/ambient_v1.pth" 71 | } 72 | ] 73 | } -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | def ensure_ai_dependencies(): 5 | """ 6 | Ensure AI dependencies are properly available 7 | Since TMIDIX and x_transformer_2_3_1 are now in site-packages, 8 | we just need to pre-import core dependencies 9 | """ 10 | try: 11 | # Pre-import critical dependencies 12 | import struct 13 | import traceback 14 | import tempfile 15 | import threading 16 | import time 17 | import datetime 18 | import collections 19 | import itertools 20 | import functools 21 | import warnings 22 | import statistics 23 | import abc 24 | import typing 25 | 26 | # Third-party dependencies 27 | import tqdm 28 | import torch 29 | import torch.nn 30 | import torch.nn.functional 31 | import numpy 32 | import random 33 | import math 34 | import copy 35 | import pickle 36 | import einops 37 | import einx 38 | 39 | # Make them available in builtins 40 | import builtins 41 | # Standard library 42 | builtins.struct = struct 43 | builtins.traceback = traceback 44 | builtins.tempfile = tempfile 45 | builtins.threading = threading 46 | builtins.time = time 47 | builtins.datetime = datetime 48 | builtins.collections = collections 49 | builtins.itertools = itertools 50 | builtins.functools = functools 51 | builtins.warnings = warnings 52 | builtins.statistics = statistics 53 | builtins.abc = abc 54 | builtins.typing = typing 55 | 56 | # Third-party 57 | builtins.tqdm = tqdm 58 | builtins.torch = torch 59 | builtins.np = numpy 60 | builtins.numpy = numpy 61 | builtins.random = random 62 | builtins.math = math 63 | builtins.copy = copy 64 | builtins.pickle = pickle 65 | builtins.einops = einops 66 | builtins.einx = einx 67 | 68 | print("✅ Pre-imported AI dependencies successfully") 69 | return True 70 | 71 | except Exception as e: 72 | print(f"⚠️ Warning: Could not pre-import some AI dependencies: {e}") 73 | return False 74 | 75 | def get_resource_path(relative_path: str, app_is_frozen: bool = hasattr(sys, 'frozen'), is_external_to_bundle: bool = False) -> str: 76 | """ 77 | Get the absolute path to a resource. 78 | - In a PyInstaller bundle: 79 | - If is_external_to_bundle is True (e.g., for a 'plugins' folder next to the .exe): 80 | Uses the directory of the executable (sys.executable). 81 | - Otherwise (e.g., for bundled 'assets', 'soundbank' in _MEIPASS): 82 | Uses PyInstaller's temporary folder (sys._MEIPASS). 83 | - In development mode (not bundled): 84 | Uses paths relative to this script's directory (utils.py, assumed to be project root). 85 | """ 86 | if app_is_frozen: # Running as a PyInstaller bundle 87 | if is_external_to_bundle: 88 | # Path relative to the executable (e.g., for a 'plugins' folder) 89 | base_path = os.path.dirname(sys.executable) 90 | else: 91 | # Path within the PyInstaller bundle (e.g., for 'assets', 'soundbank') 92 | try: 93 | base_path = sys._MEIPASS 94 | except AttributeError: 95 | # Fallback if _MEIPASS is not set for some reason 96 | print("Warning: sys._MEIPASS not found in frozen app, falling back to executable directory for bundled resource.") 97 | base_path = os.path.dirname(sys.executable) 98 | else: 99 | # Development mode: utils.py is at the project root. 100 | # Paths are relative to the project root. 101 | base_path = os.path.abspath(os.path.dirname(__file__)) 102 | 103 | return os.path.join(base_path, relative_path) 104 | 105 | if __name__ == '__main__': 106 | # Test 107 | print("Testing AI dependency setup...") 108 | success = ensure_ai_dependencies() 109 | print(f"Result: {'✅ Success' if success else '❌ Failed'}") 110 | 111 | # Test AI module imports 112 | try: 113 | import TMIDIX 114 | print("✅ TMIDIX can be imported from site-packages") 115 | if hasattr(TMIDIX, '__file__'): 116 | print(f" Location: {TMIDIX.__file__}") 117 | except ImportError as e: 118 | print(f"❌ TMIDIX import failed: {e}") 119 | 120 | try: 121 | import x_transformer_2_3_1 122 | print("✅ x_transformer_2_3_1 can be imported from site-packages") 123 | if hasattr(x_transformer_2_3_1, '__file__'): 124 | print(f" Location: {x_transformer_2_3_1.__file__}") 125 | except ImportError as e: 126 | print(f"❌ x_transformer_2_3_1 import failed: {e}") --------------------------------------------------------------------------------