├── .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 |
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 |
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 |
30 |
31 |
32 |
33 | ---
34 |
35 | ## 📖 Project Information
36 |
37 |
38 |
39 |
Check out the latest features, previews, and technical documentation
40 |
41 |
42 | ---
43 |
44 |
49 |
50 | ---
51 |
52 | ## ⭐ Repository Stats
53 |
54 |
65 |
66 |
67 |
68 |
🏛️ Legacy MIDI Gen Repository
69 |
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 |
97 |
🤖 AI Setup Interface
98 |
99 |
100 |
📊 Dashboard Overview
101 |
102 |
103 |
🎛️ Musical Configuration Panel
104 |
105 |
106 |
🎹 Velocity & Notes Editor
107 |
108 |
109 | ---
110 |
111 | ## 🚀 Migration Guide
112 |
113 | ### 🔄 **Moving from MIDI Gen to MuseCraft Studio**
114 |
115 |
116 |
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 |
136 |
137 | ---
138 |
139 | ## 🙏💡 Powered by Open Source Excellence
140 |
141 |
142 |
143 |
144 |
🎵 Special Thanks to @asigalov61 for providing:
145 |
146 | 🤖 Melody AI Model – Advanced musical generation algorithms
147 | 🎼 TMIDIX & X-Transformer – Core MIDI processing and neural transformer architecture
148 |
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 |
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, '');
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}")
--------------------------------------------------------------------------------