You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1005 lines
39 KiB
1005 lines
39 KiB
#!/usr/bin/env python3
|
|
"""
|
|
Master Preset Generator GUI for MIDI Arpeggiator
|
|
|
|
Interactive GUI for creating master preset files with customizable parameters.
|
|
Maintains consistent synth count for live performance use.
|
|
"""
|
|
|
|
import json
|
|
import copy
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Dict, List, Any, Tuple
|
|
import math
|
|
|
|
from PyQt5.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
|
QPushButton, QLabel, QSpinBox, QDoubleSpinBox, QComboBox, QSlider,
|
|
QGroupBox, QFileDialog, QLineEdit, QTextEdit, QProgressBar, QCheckBox,
|
|
QMessageBox, QTabWidget, QFormLayout, QFrame, QScrollArea
|
|
)
|
|
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, pyqtSlot
|
|
from PyQt5.QtGui import QFont, QPalette, QColor, QPixmap
|
|
|
|
# Embedded generator classes
|
|
class MusicalLogic:
|
|
"""Handles musical theory and harmonic progressions"""
|
|
|
|
def __init__(self):
|
|
# Circle of fifths progression (musically smooth root note changes)
|
|
self.circle_of_fifths = [0, 7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5] # C,G,D,A,E,B,F#,C#,G#,D#,A#,F
|
|
|
|
# Scale relationships - which scales work well together
|
|
self.scale_relationships = {
|
|
"major": ["lydian", "mixolydian", "major"],
|
|
"minor": ["dorian", "phrygian", "minor"],
|
|
"mixolydian": ["major", "dorian", "mixolydian"],
|
|
"dorian": ["minor", "mixolydian", "dorian"],
|
|
"phrygian": ["minor", "dorian", "phrygian"],
|
|
"lydian": ["major", "mixolydian", "lydian"],
|
|
"pentatonic": ["major", "minor", "pentatonic"]
|
|
}
|
|
|
|
# Note names for display
|
|
self.note_names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
|
|
def get_next_root_note(self, current_root: int, progression_type: str = "circle_of_fifths") -> int:
|
|
"""Get next root note following musical logic"""
|
|
if progression_type == "circle_of_fifths":
|
|
current_note = current_root % 12
|
|
try:
|
|
current_index = self.circle_of_fifths.index(current_note)
|
|
next_index = (current_index + 1) % len(self.circle_of_fifths)
|
|
return (current_root // 12) * 12 + self.circle_of_fifths[next_index]
|
|
except ValueError:
|
|
# If current note not in circle, go to nearest one
|
|
return current_root + 7 # Perfect fifth up
|
|
elif progression_type == "chromatic_up":
|
|
return current_root + 1
|
|
elif progression_type == "chromatic_down":
|
|
return current_root - 1
|
|
return current_root
|
|
|
|
|
|
class PresetTemplate:
|
|
"""Manages preset template and parameter ranges"""
|
|
|
|
def __init__(self, base_preset: Dict[str, Any]):
|
|
self.template = copy.deepcopy(base_preset)
|
|
self.locked_parameters = {
|
|
# CRITICAL: These never change for live performance
|
|
"active_synth_count",
|
|
"channel_instruments",
|
|
}
|
|
|
|
# Define safe parameter ranges
|
|
self.parameter_ranges = {
|
|
"tempo": {"min": 80, "max": 140, "step": 2},
|
|
"velocity": {"min": 60, "max": 127, "step": 3},
|
|
"gate": {"min": 0.4, "max": 1.2, "step": 0.03},
|
|
"swing": {"min": -0.25, "max": 0.3, "step": 0.03},
|
|
"delay_fade": {"min": 0.2, "max": 0.8, "step": 0.04},
|
|
"delay_length": {"min": 1, "max": 6, "step": 1},
|
|
"user_pattern_length": {"min": 3, "max": 12, "step": 1},
|
|
"scale_note_start": {"min": 0, "max": 6, "step": 1},
|
|
"octave_range": {"min": 1, "max": 3, "step": 1},
|
|
}
|
|
|
|
def clamp_parameter(self, param_name: str, value: Any) -> Any:
|
|
"""Ensure parameter stays within safe bounds"""
|
|
if param_name in self.parameter_ranges:
|
|
range_info = self.parameter_ranges[param_name]
|
|
return max(range_info["min"], min(range_info["max"], value))
|
|
return value
|
|
|
|
|
|
class GenerationStrategies:
|
|
"""Different strategies for generating preset progressions"""
|
|
|
|
@staticmethod
|
|
def build_and_release(base_preset: Dict[str, Any], count: int, musical_logic: MusicalLogic, template: PresetTemplate) -> List[Tuple[str, Dict[str, Any]]]:
|
|
"""Build intensity to 70% point, then release back down"""
|
|
presets = []
|
|
peak_point = int(count * 0.7)
|
|
|
|
for i in range(count):
|
|
preset = copy.deepcopy(base_preset)
|
|
|
|
# Calculate progression factor (0.0 to 1.0 and back down)
|
|
if i <= peak_point:
|
|
factor = i / peak_point # 0.0 to 1.0
|
|
else:
|
|
factor = 1.0 - ((i - peak_point) / (count - peak_point)) # 1.0 back to 0.0
|
|
|
|
# Apply gradual changes
|
|
arp = preset["arpeggiator"]
|
|
|
|
# Tempo progression
|
|
base_tempo = base_preset["arpeggiator"]["tempo"]
|
|
tempo_range = 15 # +/- 15 BPM (more subtle)
|
|
arp["tempo"] = base_tempo + (tempo_range * factor * 0.8)
|
|
|
|
# Velocity progression
|
|
base_velocity = base_preset["arpeggiator"]["velocity"]
|
|
velocity_range = 15 # More subtle
|
|
arp["velocity"] = int(base_velocity + (velocity_range * factor * 0.6))
|
|
|
|
# Gate progression (tighter at peak)
|
|
base_gate = base_preset["arpeggiator"]["gate"]
|
|
gate_change = 0.1 * factor # More subtle
|
|
arp["gate"] = template.clamp_parameter("gate", base_gate + gate_change)
|
|
|
|
# Swing progression (add swing as it builds)
|
|
base_swing = base_preset["arpeggiator"]["swing"]
|
|
swing_change = 0.1 * factor # More subtle
|
|
arp["swing"] = template.clamp_parameter("swing", base_swing + swing_change)
|
|
|
|
# Pattern length progression (more conservative)
|
|
base_length = base_preset["arpeggiator"]["user_pattern_length"]
|
|
length_change = int(2 * factor) # Add up to 2 notes at peak
|
|
arp["user_pattern_length"] = template.clamp_parameter("user_pattern_length", base_length + length_change)
|
|
|
|
# Delay progression
|
|
if arp.get("delay_enabled", False):
|
|
base_fade = base_preset["arpeggiator"]["delay_fade"]
|
|
fade_change = 0.15 * factor
|
|
arp["delay_fade"] = template.clamp_parameter("delay_fade", base_fade + fade_change)
|
|
|
|
# Add some variety every few presets (very subtle)
|
|
if i > 0 and i % 5 == 0:
|
|
# Subtle scale note shift
|
|
current_start = arp.get("scale_note_start", 0)
|
|
new_start = (current_start + 1) % 4 # Only first 4 scale notes
|
|
arp["scale_note_start"] = new_start
|
|
|
|
# Generate preset name
|
|
preset_name = f"preset_{i+1:02d}_{int(factor*100):02d}pct"
|
|
presets.append((preset_name, preset))
|
|
|
|
return presets
|
|
|
|
@staticmethod
|
|
def modal_journey(base_preset: Dict[str, Any], count: int, musical_logic: MusicalLogic, template: PresetTemplate) -> List[Tuple[str, Dict[str, Any]]]:
|
|
"""Progress through related musical modes"""
|
|
presets = []
|
|
|
|
# Plan a gentle modal journey
|
|
scale_sequence = ["major", "mixolydian", "dorian", "minor", "dorian", "mixolydian", "major"]
|
|
|
|
for i in range(count):
|
|
preset = copy.deepcopy(base_preset)
|
|
arp = preset["arpeggiator"]
|
|
|
|
# Progress through scales gently
|
|
scale_idx = int((i / max(1, count - 1)) * (len(scale_sequence) - 1))
|
|
arp["scale"] = scale_sequence[scale_idx]
|
|
|
|
# Very subtle tempo drift
|
|
base_tempo = base_preset["arpeggiator"]["tempo"]
|
|
tempo_drift = math.sin(i / count * math.pi * 2) * 5 # +/- 5 BPM sine wave
|
|
arp["tempo"] = base_tempo + tempo_drift
|
|
|
|
preset_name = f"modal_{scale_sequence[scale_idx]}_{i+1:02d}"
|
|
presets.append((preset_name, preset))
|
|
|
|
return presets
|
|
|
|
|
|
class PresetValidator:
|
|
"""Validates generated presets for quality and live performance suitability"""
|
|
|
|
def validate_preset(self, preset: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
|
"""Validate a single preset"""
|
|
errors = []
|
|
|
|
arp = preset.get("arpeggiator", {})
|
|
|
|
# Check critical parameters are within performance ranges
|
|
tempo = arp.get("tempo", 120)
|
|
if tempo < 70 or tempo > 150:
|
|
errors.append(f"Tempo {tempo} outside live performance range (70-150)")
|
|
|
|
velocity = arp.get("velocity", 100)
|
|
if velocity < 1 or velocity > 127:
|
|
errors.append(f"Velocity {velocity} outside MIDI range (1-127)")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
class MasterPresetGenerator:
|
|
"""Main generator class"""
|
|
|
|
def __init__(self, base_preset_path: str = None):
|
|
if base_preset_path:
|
|
with open(base_preset_path, 'r') as f:
|
|
self.base_preset = json.load(f)
|
|
else:
|
|
self.base_preset = None
|
|
|
|
self.musical_logic = MusicalLogic()
|
|
if self.base_preset:
|
|
self.template = PresetTemplate(self.base_preset)
|
|
self.validator = PresetValidator()
|
|
|
|
def generate_master_preset(self,
|
|
name: str,
|
|
count: int,
|
|
strategy: str = "build_and_release",
|
|
loop_count: int = 4) -> Dict[str, Any]:
|
|
"""Generate a complete master preset file"""
|
|
|
|
# Generate presets using selected strategy
|
|
if strategy == "build_and_release":
|
|
preset_list = GenerationStrategies.build_and_release(
|
|
self.base_preset, count, self.musical_logic, self.template
|
|
)
|
|
elif strategy == "modal_journey":
|
|
preset_list = GenerationStrategies.modal_journey(
|
|
self.base_preset, count, self.musical_logic, self.template
|
|
)
|
|
else:
|
|
raise ValueError(f"Unknown strategy: {strategy}")
|
|
|
|
# Build master preset structure
|
|
master_preset = {
|
|
"version": "1.0",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"type": "master_file",
|
|
"presets": {},
|
|
"preset_group": {
|
|
"enabled": True,
|
|
"presets": [],
|
|
"loop_count": loop_count,
|
|
"order": "in_order",
|
|
"current_index": 0,
|
|
"current_loops": 0
|
|
}
|
|
}
|
|
|
|
# Add all generated presets
|
|
for preset_name, preset in preset_list:
|
|
# Add timestamp to each preset
|
|
preset["timestamp"] = datetime.now().isoformat()
|
|
preset["version"] = "1.0"
|
|
master_preset["presets"][preset_name] = preset
|
|
master_preset["preset_group"]["presets"].append(preset_name)
|
|
|
|
return master_preset
|
|
|
|
|
|
class PresetGeneratorThread(QThread):
|
|
"""Background thread for generating presets"""
|
|
|
|
progress_update = pyqtSignal(int, str) # progress, message
|
|
generation_complete = pyqtSignal(dict) # master_preset
|
|
error_occurred = pyqtSignal(str) # error_message
|
|
|
|
def __init__(self, generator, name, count, strategy, loop_count, advanced_settings):
|
|
super().__init__()
|
|
self.generator = generator
|
|
self.name = name
|
|
self.count = count
|
|
self.strategy = strategy
|
|
self.loop_count = loop_count
|
|
self.advanced_settings = advanced_settings
|
|
|
|
def run(self):
|
|
try:
|
|
self.progress_update.emit(10, "Initializing generator...")
|
|
|
|
# Apply advanced settings to generator
|
|
self.apply_advanced_settings()
|
|
|
|
self.progress_update.emit(30, f"Generating {self.count} presets...")
|
|
|
|
# Generate the master preset
|
|
master_preset = self.generator.generate_master_preset(
|
|
self.name, self.count, self.strategy, self.loop_count
|
|
)
|
|
|
|
self.progress_update.emit(90, "Validating presets...")
|
|
|
|
# Final validation
|
|
preset_count = len(master_preset["presets"])
|
|
self.progress_update.emit(100, f"Complete! Generated {preset_count} presets")
|
|
|
|
self.generation_complete.emit(master_preset)
|
|
|
|
except Exception as e:
|
|
self.error_occurred.emit(str(e))
|
|
|
|
def apply_advanced_settings(self):
|
|
"""Apply advanced settings to the generator"""
|
|
# Modify parameter ranges based on advanced settings
|
|
template = self.generator.template
|
|
|
|
if "tempo_range" in self.advanced_settings:
|
|
min_tempo, max_tempo = self.advanced_settings["tempo_range"]
|
|
template.parameter_ranges["tempo"]["min"] = min_tempo
|
|
template.parameter_ranges["tempo"]["max"] = max_tempo
|
|
|
|
if "velocity_range" in self.advanced_settings:
|
|
min_vel, max_vel = self.advanced_settings["velocity_range"]
|
|
template.parameter_ranges["velocity"]["min"] = min_vel
|
|
template.parameter_ranges["velocity"]["max"] = max_vel
|
|
|
|
# Add more advanced settings as needed
|
|
|
|
|
|
class AdvancedSettingsWidget(QWidget):
|
|
"""Advanced settings panel"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.init_ui()
|
|
|
|
def init_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Tempo Range Settings
|
|
tempo_group = QGroupBox("Tempo Range")
|
|
tempo_layout = QFormLayout(tempo_group)
|
|
|
|
self.min_tempo_spin = QSpinBox()
|
|
self.min_tempo_spin.setRange(60, 180)
|
|
self.min_tempo_spin.setValue(90)
|
|
self.min_tempo_spin.setSuffix(" BPM")
|
|
|
|
self.max_tempo_spin = QSpinBox()
|
|
self.max_tempo_spin.setRange(60, 180)
|
|
self.max_tempo_spin.setValue(140)
|
|
self.max_tempo_spin.setSuffix(" BPM")
|
|
|
|
tempo_layout.addRow("Minimum Tempo:", self.min_tempo_spin)
|
|
tempo_layout.addRow("Maximum Tempo:", self.max_tempo_spin)
|
|
|
|
# Velocity Range Settings
|
|
velocity_group = QGroupBox("Velocity Range")
|
|
velocity_layout = QFormLayout(velocity_group)
|
|
|
|
self.min_velocity_spin = QSpinBox()
|
|
self.min_velocity_spin.setRange(1, 127)
|
|
self.min_velocity_spin.setValue(60)
|
|
|
|
self.max_velocity_spin = QSpinBox()
|
|
self.max_velocity_spin.setRange(1, 127)
|
|
self.max_velocity_spin.setValue(127)
|
|
|
|
velocity_layout.addRow("Minimum Velocity:", self.min_velocity_spin)
|
|
velocity_layout.addRow("Maximum Velocity:", self.max_velocity_spin)
|
|
|
|
# Pattern Settings
|
|
pattern_group = QGroupBox("Pattern Constraints")
|
|
pattern_layout = QFormLayout(pattern_group)
|
|
|
|
self.min_pattern_length_spin = QSpinBox()
|
|
self.min_pattern_length_spin.setRange(2, 16)
|
|
self.min_pattern_length_spin.setValue(3)
|
|
|
|
self.max_pattern_length_spin = QSpinBox()
|
|
self.max_pattern_length_spin.setRange(2, 16)
|
|
self.max_pattern_length_spin.setValue(8)
|
|
|
|
pattern_layout.addRow("Min Pattern Length:", self.min_pattern_length_spin)
|
|
pattern_layout.addRow("Max Pattern Length:", self.max_pattern_length_spin)
|
|
|
|
# Scale Progression Settings
|
|
scale_group = QGroupBox("Scale Progression")
|
|
scale_layout = QFormLayout(scale_group)
|
|
|
|
self.root_note_progression = QComboBox()
|
|
self.root_note_progression.addItems([
|
|
"Stay Same", "Circle of Fifths", "Chromatic Up", "Chromatic Down"
|
|
])
|
|
self.root_note_progression.setCurrentText("Circle of Fifths")
|
|
|
|
self.scale_progression = QComboBox()
|
|
self.scale_progression.addItems([
|
|
"Stay Same", "Modal Journey", "Major/Minor Only", "All Modes"
|
|
])
|
|
self.scale_progression.setCurrentText("Modal Journey")
|
|
|
|
scale_layout.addRow("Root Note Movement:", self.root_note_progression)
|
|
scale_layout.addRow("Scale Changes:", self.scale_progression)
|
|
|
|
# Intensity Settings
|
|
intensity_group = QGroupBox("Intensity Curve")
|
|
intensity_layout = QFormLayout(intensity_group)
|
|
|
|
self.build_percentage_spin = QSpinBox()
|
|
self.build_percentage_spin.setRange(50, 90)
|
|
self.build_percentage_spin.setValue(70)
|
|
self.build_percentage_spin.setSuffix("%")
|
|
|
|
self.intensity_factor_spin = QDoubleSpinBox()
|
|
self.intensity_factor_spin.setRange(0.5, 2.0)
|
|
self.intensity_factor_spin.setValue(1.0)
|
|
self.intensity_factor_spin.setSingleStep(0.1)
|
|
|
|
intensity_layout.addRow("Build to Peak at:", self.build_percentage_spin)
|
|
intensity_layout.addRow("Intensity Factor:", self.intensity_factor_spin)
|
|
|
|
# Add all groups
|
|
layout.addWidget(tempo_group)
|
|
layout.addWidget(velocity_group)
|
|
layout.addWidget(pattern_group)
|
|
layout.addWidget(scale_group)
|
|
layout.addWidget(intensity_group)
|
|
layout.addStretch()
|
|
|
|
def get_settings(self) -> Dict[str, Any]:
|
|
"""Get current advanced settings as dictionary"""
|
|
return {
|
|
"tempo_range": (self.min_tempo_spin.value(), self.max_tempo_spin.value()),
|
|
"velocity_range": (self.min_velocity_spin.value(), self.max_velocity_spin.value()),
|
|
"pattern_length_range": (self.min_pattern_length_spin.value(), self.max_pattern_length_spin.value()),
|
|
"root_note_progression": self.root_note_progression.currentText(),
|
|
"scale_progression": self.scale_progression.currentText(),
|
|
"build_percentage": self.build_percentage_spin.value() / 100.0,
|
|
"intensity_factor": self.intensity_factor_spin.value(),
|
|
}
|
|
|
|
|
|
class PresetPreviewWidget(QWidget):
|
|
"""Widget for previewing generated presets"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.master_preset = None
|
|
self.init_ui()
|
|
|
|
def init_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Preview info
|
|
info_layout = QHBoxLayout()
|
|
self.preset_count_label = QLabel("Presets: 0")
|
|
self.total_duration_label = QLabel("Est. Duration: 0:00")
|
|
info_layout.addWidget(self.preset_count_label)
|
|
info_layout.addWidget(self.total_duration_label)
|
|
info_layout.addStretch()
|
|
|
|
layout.addLayout(info_layout)
|
|
|
|
# Preset list
|
|
self.preset_list = QTextEdit()
|
|
self.preset_list.setMaximumHeight(200)
|
|
self.preset_list.setReadOnly(True)
|
|
layout.addWidget(QLabel("Preset Overview:"))
|
|
layout.addWidget(self.preset_list)
|
|
|
|
# Parameter progression charts (simplified text display)
|
|
self.progression_display = QTextEdit()
|
|
self.progression_display.setReadOnly(True)
|
|
layout.addWidget(QLabel("Parameter Progression:"))
|
|
layout.addWidget(self.progression_display)
|
|
|
|
def update_preview(self, master_preset: Dict[str, Any]):
|
|
"""Update preview with generated master preset"""
|
|
self.master_preset = master_preset
|
|
|
|
presets = master_preset.get("presets", {})
|
|
preset_group = master_preset.get("preset_group", {})
|
|
|
|
# Update counts
|
|
preset_count = len(presets)
|
|
loop_count = preset_group.get("loop_count", 1)
|
|
|
|
# Estimate duration (rough calculation)
|
|
avg_tempo = 120 # Default assumption
|
|
if presets:
|
|
first_preset = list(presets.values())[0]
|
|
avg_tempo = first_preset.get("arpeggiator", {}).get("tempo", 120)
|
|
|
|
# Very rough duration estimate
|
|
estimated_minutes = (preset_count * loop_count * 4) / (avg_tempo / 60) / 4 # 4 bars per preset estimate
|
|
duration_str = f"{int(estimated_minutes)}:{int((estimated_minutes % 1) * 60):02d}"
|
|
|
|
self.preset_count_label.setText(f"Presets: {preset_count}")
|
|
self.total_duration_label.setText(f"Est. Duration: {duration_str}")
|
|
|
|
# Update preset list
|
|
preset_list_text = ""
|
|
for i, (name, preset_data) in enumerate(presets.items()):
|
|
arp = preset_data.get("arpeggiator", {})
|
|
tempo = arp.get("tempo", 120)
|
|
scale = arp.get("scale", "major")
|
|
pattern = arp.get("pattern_type", "up")
|
|
root_note = arp.get("root_note", 60)
|
|
note_name = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"][root_note % 12]
|
|
|
|
preset_list_text += f"{i+1:2d}. {name}: {note_name} {scale} {pattern} @ {tempo:.0f}BPM\n"
|
|
|
|
self.preset_list.setPlainText(preset_list_text)
|
|
|
|
# Update progression display
|
|
self.update_progression_display()
|
|
|
|
def update_progression_display(self):
|
|
"""Show parameter progression analysis"""
|
|
if not self.master_preset:
|
|
return
|
|
|
|
presets = self.master_preset.get("presets", {})
|
|
if not presets:
|
|
return
|
|
|
|
progression_text = ""
|
|
|
|
# Analyze tempo progression
|
|
tempos = []
|
|
velocities = []
|
|
gates = []
|
|
|
|
for preset_data in presets.values():
|
|
arp = preset_data.get("arpeggiator", {})
|
|
tempos.append(arp.get("tempo", 120))
|
|
velocities.append(arp.get("velocity", 100))
|
|
gates.append(arp.get("gate", 1.0))
|
|
|
|
if tempos:
|
|
progression_text += f"Tempo Range: {min(tempos):.0f} - {max(tempos):.0f} BPM\n"
|
|
progression_text += f"Velocity Range: {min(velocities)} - {max(velocities)}\n"
|
|
progression_text += f"Gate Range: {min(gates):.2f} - {max(gates):.2f}\n\n"
|
|
|
|
# Show progression pattern
|
|
progression_text += "Tempo Progression:\n"
|
|
for i, tempo in enumerate(tempos[::max(1, len(tempos)//10)]): # Show every 10th or so
|
|
progression_text += f" {i*max(1, len(tempos)//10)+1:2d}: {tempo:.0f} BPM\n"
|
|
|
|
self.progression_display.setPlainText(progression_text)
|
|
|
|
|
|
class MasterPresetGeneratorGUI(QMainWindow):
|
|
"""Main GUI window for master preset generation"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.base_preset = None
|
|
self.generator = None
|
|
self.generated_master_preset = None
|
|
|
|
self.init_ui()
|
|
self.apply_dark_theme()
|
|
|
|
# Set default paths
|
|
self.load_base_preset("presets/two.json") # Try to load default
|
|
|
|
def init_ui(self):
|
|
self.setWindowTitle("Master Preset Generator - MIDI Arpeggiator")
|
|
self.setMinimumSize(1000, 700)
|
|
|
|
# Create central widget with tabs
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
main_layout = QVBoxLayout(central_widget)
|
|
|
|
# File selection section
|
|
file_section = self.create_file_section()
|
|
main_layout.addWidget(file_section)
|
|
|
|
# Tab widget for main content
|
|
tab_widget = QTabWidget()
|
|
main_layout.addWidget(tab_widget)
|
|
|
|
# Basic Settings Tab
|
|
basic_tab = self.create_basic_settings_tab()
|
|
tab_widget.addTab(basic_tab, "Basic Settings")
|
|
|
|
# Advanced Settings Tab
|
|
self.advanced_widget = AdvancedSettingsWidget()
|
|
tab_widget.addTab(self.advanced_widget, "Advanced Settings")
|
|
|
|
# Preview Tab
|
|
self.preview_widget = PresetPreviewWidget()
|
|
tab_widget.addTab(self.preview_widget, "Preview")
|
|
|
|
# Generation controls
|
|
generation_section = self.create_generation_section()
|
|
main_layout.addWidget(generation_section)
|
|
|
|
# Status bar
|
|
self.statusBar().showMessage("Ready - Load a base preset to begin")
|
|
|
|
def create_file_section(self):
|
|
"""Create file selection section"""
|
|
group = QGroupBox("Base Preset")
|
|
layout = QHBoxLayout(group)
|
|
|
|
self.base_preset_path = QLineEdit()
|
|
self.base_preset_path.setReadOnly(True)
|
|
self.base_preset_path.setPlaceholderText("Select base preset file...")
|
|
|
|
browse_button = QPushButton("Browse...")
|
|
browse_button.clicked.connect(self.browse_base_preset)
|
|
|
|
# Base preset info display
|
|
self.base_preset_info = QLabel("No preset loaded")
|
|
self.base_preset_info.setStyleSheet("color: #888888;")
|
|
|
|
layout.addWidget(QLabel("Base Preset:"))
|
|
layout.addWidget(self.base_preset_path)
|
|
layout.addWidget(browse_button)
|
|
layout.addWidget(self.base_preset_info)
|
|
layout.addStretch()
|
|
|
|
return group
|
|
|
|
def create_basic_settings_tab(self):
|
|
"""Create basic settings tab"""
|
|
widget = QWidget()
|
|
layout = QVBoxLayout(widget)
|
|
|
|
# Master preset settings
|
|
master_group = QGroupBox("Master Preset Settings")
|
|
master_layout = QFormLayout(master_group)
|
|
|
|
self.master_name = QLineEdit("generated_master")
|
|
self.preset_count = QSpinBox()
|
|
self.preset_count.setRange(5, 50)
|
|
self.preset_count.setValue(16)
|
|
|
|
self.loop_count = QSpinBox()
|
|
self.loop_count.setRange(1, 20)
|
|
self.loop_count.setValue(4)
|
|
self.loop_count.setToolTip("How many times each preset loops before advancing")
|
|
|
|
master_layout.addRow("Master Preset Name:", self.master_name)
|
|
master_layout.addRow("Number of Presets:", self.preset_count)
|
|
master_layout.addRow("Loop Count per Preset:", self.loop_count)
|
|
|
|
# Generation strategy
|
|
strategy_group = QGroupBox("Generation Strategy")
|
|
strategy_layout = QFormLayout(strategy_group)
|
|
|
|
self.strategy_combo = QComboBox()
|
|
self.strategy_combo.addItems(["build_and_release", "modal_journey"])
|
|
self.strategy_combo.currentTextChanged.connect(self.on_strategy_changed)
|
|
|
|
self.strategy_description = QLabel()
|
|
self.strategy_description.setWordWrap(True)
|
|
self.strategy_description.setStyleSheet("color: #cccccc; font-style: italic;")
|
|
|
|
strategy_layout.addRow("Strategy:", self.strategy_combo)
|
|
strategy_layout.addRow("Description:", self.strategy_description)
|
|
|
|
# Update description for default strategy
|
|
self.on_strategy_changed("build_and_release")
|
|
|
|
# Quick settings
|
|
quick_group = QGroupBox("Quick Settings")
|
|
quick_layout = QFormLayout(quick_group)
|
|
|
|
self.subtle_changes = QCheckBox("Subtle Changes Only")
|
|
self.subtle_changes.setChecked(True)
|
|
self.subtle_changes.setToolTip("Keep all parameter changes small and gradual")
|
|
|
|
self.preserve_feel = QCheckBox("Preserve Musical Feel")
|
|
self.preserve_feel.setChecked(True)
|
|
self.preserve_feel.setToolTip("Maintain the character of the base preset")
|
|
|
|
self.live_performance = QCheckBox("Live Performance Mode")
|
|
self.live_performance.setChecked(True)
|
|
self.live_performance.setToolTip("Optimize for live performance (no fast notes, stable synth count)")
|
|
|
|
quick_layout.addRow(self.subtle_changes)
|
|
quick_layout.addRow(self.preserve_feel)
|
|
quick_layout.addRow(self.live_performance)
|
|
|
|
# Add all groups
|
|
layout.addWidget(master_group)
|
|
layout.addWidget(strategy_group)
|
|
layout.addWidget(quick_group)
|
|
layout.addStretch()
|
|
|
|
return widget
|
|
|
|
def create_generation_section(self):
|
|
"""Create generation controls section"""
|
|
group = QGroupBox("Generation")
|
|
layout = QVBoxLayout(group)
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setVisible(False)
|
|
|
|
self.progress_label = QLabel("")
|
|
self.progress_label.setVisible(False)
|
|
|
|
# Buttons
|
|
button_layout = QHBoxLayout()
|
|
|
|
self.generate_button = QPushButton("Generate Master Preset")
|
|
self.generate_button.setStyleSheet("""
|
|
QPushButton {
|
|
background: #2d5a2d;
|
|
color: white;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
padding: 12px 24px;
|
|
border-radius: 6px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #3d6a3d;
|
|
}
|
|
QPushButton:disabled {
|
|
background: #555555;
|
|
color: #999999;
|
|
}
|
|
""")
|
|
self.generate_button.clicked.connect(self.generate_master_preset)
|
|
|
|
self.save_button = QPushButton("Save Master Preset")
|
|
self.save_button.setEnabled(False)
|
|
self.save_button.setStyleSheet("""
|
|
QPushButton {
|
|
background: #5a2d5a;
|
|
color: white;
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
padding: 12px 24px;
|
|
border-radius: 6px;
|
|
}
|
|
QPushButton:hover {
|
|
background: #6a3d6a;
|
|
}
|
|
QPushButton:disabled {
|
|
background: #555555;
|
|
color: #999999;
|
|
}
|
|
""")
|
|
self.save_button.clicked.connect(self.save_master_preset)
|
|
|
|
button_layout.addWidget(self.generate_button)
|
|
button_layout.addWidget(self.save_button)
|
|
button_layout.addStretch()
|
|
|
|
layout.addWidget(self.progress_bar)
|
|
layout.addWidget(self.progress_label)
|
|
layout.addLayout(button_layout)
|
|
|
|
return group
|
|
|
|
def browse_base_preset(self):
|
|
"""Browse for base preset file"""
|
|
file_path, _ = QFileDialog.getOpenFileName(
|
|
self, "Select Base Preset", "presets/", "JSON Files (*.json)"
|
|
)
|
|
if file_path:
|
|
self.load_base_preset(file_path)
|
|
|
|
def load_base_preset(self, file_path: str):
|
|
"""Load base preset from file"""
|
|
try:
|
|
if not os.path.exists(file_path):
|
|
return
|
|
|
|
with open(file_path, 'r') as f:
|
|
self.base_preset = json.load(f)
|
|
|
|
self.base_preset_path.setText(file_path)
|
|
|
|
# Update info display
|
|
arp = self.base_preset.get("arpeggiator", {})
|
|
channels = self.base_preset.get("channels", {})
|
|
|
|
info_text = f"Scale: {arp.get('scale', 'unknown')} | "
|
|
info_text += f"Tempo: {arp.get('tempo', 120)} BPM | "
|
|
info_text += f"Synths: {channels.get('active_synth_count', '?')}"
|
|
|
|
self.base_preset_info.setText(info_text)
|
|
self.base_preset_info.setStyleSheet("color: #00aa00;")
|
|
|
|
# Create generator
|
|
self.generator = MasterPresetGenerator.__new__(MasterPresetGenerator)
|
|
self.generator.base_preset = self.base_preset
|
|
self.generator.musical_logic = MusicalLogic()
|
|
self.generator.template = PresetTemplate(self.base_preset)
|
|
self.generator.validator = PresetValidator()
|
|
|
|
self.statusBar().showMessage(f"Loaded base preset: {os.path.basename(file_path)}")
|
|
self.generate_button.setEnabled(True)
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Error Loading Preset", f"Could not load preset file:\n{str(e)}")
|
|
self.statusBar().showMessage("Error loading preset")
|
|
|
|
def on_strategy_changed(self, strategy: str):
|
|
"""Update strategy description"""
|
|
descriptions = {
|
|
"build_and_release": "Gradually builds intensity to a peak (70% through), then releases back down. Perfect for sets that need a musical arc with climax and resolution.",
|
|
"modal_journey": "Progresses through related musical modes and scales, with gentle root note changes. Great for ambient and experimental performances."
|
|
}
|
|
|
|
self.strategy_description.setText(descriptions.get(strategy, "Unknown strategy"))
|
|
|
|
def generate_master_preset(self):
|
|
"""Generate master preset in background thread"""
|
|
if not self.base_preset or not self.generator:
|
|
QMessageBox.warning(self, "No Base Preset", "Please load a base preset first.")
|
|
return
|
|
|
|
# Get settings
|
|
name = self.master_name.text().strip()
|
|
if not name:
|
|
QMessageBox.warning(self, "Invalid Name", "Please enter a name for the master preset.")
|
|
return
|
|
|
|
count = self.preset_count.value()
|
|
strategy = self.strategy_combo.currentText()
|
|
loop_count = self.loop_count.value()
|
|
advanced_settings = self.advanced_widget.get_settings()
|
|
|
|
# Show progress
|
|
self.progress_bar.setVisible(True)
|
|
self.progress_label.setVisible(True)
|
|
self.progress_bar.setValue(0)
|
|
self.generate_button.setEnabled(False)
|
|
|
|
# Start generation thread
|
|
self.generation_thread = PresetGeneratorThread(
|
|
self.generator, name, count, strategy, loop_count, advanced_settings
|
|
)
|
|
self.generation_thread.progress_update.connect(self.on_progress_update)
|
|
self.generation_thread.generation_complete.connect(self.on_generation_complete)
|
|
self.generation_thread.error_occurred.connect(self.on_generation_error)
|
|
self.generation_thread.start()
|
|
|
|
@pyqtSlot(int, str)
|
|
def on_progress_update(self, progress: int, message: str):
|
|
"""Handle progress updates from generation thread"""
|
|
self.progress_bar.setValue(progress)
|
|
self.progress_label.setText(message)
|
|
|
|
@pyqtSlot(dict)
|
|
def on_generation_complete(self, master_preset: Dict[str, Any]):
|
|
"""Handle successful generation completion"""
|
|
self.generated_master_preset = master_preset
|
|
|
|
# Hide progress
|
|
self.progress_bar.setVisible(False)
|
|
self.progress_label.setVisible(False)
|
|
self.generate_button.setEnabled(True)
|
|
self.save_button.setEnabled(True)
|
|
|
|
# Update preview
|
|
self.preview_widget.update_preview(master_preset)
|
|
|
|
# Show success message
|
|
preset_count = len(master_preset.get("presets", {}))
|
|
QMessageBox.information(self, "Generation Complete",
|
|
f"Successfully generated {preset_count} presets!")
|
|
|
|
self.statusBar().showMessage(f"Generated {preset_count} presets - Ready to save")
|
|
|
|
@pyqtSlot(str)
|
|
def on_generation_error(self, error_message: str):
|
|
"""Handle generation errors"""
|
|
self.progress_bar.setVisible(False)
|
|
self.progress_label.setVisible(False)
|
|
self.generate_button.setEnabled(True)
|
|
|
|
QMessageBox.critical(self, "Generation Error", f"Error generating presets:\n{error_message}")
|
|
self.statusBar().showMessage("Generation failed")
|
|
|
|
def save_master_preset(self):
|
|
"""Save generated master preset to file"""
|
|
if not self.generated_master_preset:
|
|
return
|
|
|
|
name = self.master_name.text().strip()
|
|
default_filename = f"{name}.json"
|
|
|
|
file_path, _ = QFileDialog.getSaveFileName(
|
|
self, "Save Master Preset", f"master_files/{default_filename}",
|
|
"JSON Files (*.json)"
|
|
)
|
|
|
|
if file_path:
|
|
try:
|
|
# Ensure directory exists
|
|
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
|
|
with open(file_path, 'w') as f:
|
|
json.dump(self.generated_master_preset, f, indent=2)
|
|
|
|
QMessageBox.information(self, "Saved Successfully",
|
|
f"Master preset saved to:\n{file_path}")
|
|
self.statusBar().showMessage(f"Saved: {os.path.basename(file_path)}")
|
|
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Save Error", f"Could not save file:\n{str(e)}")
|
|
|
|
def apply_dark_theme(self):
|
|
"""Apply dark theme matching the main arpeggiator interface"""
|
|
dark_palette = QPalette()
|
|
|
|
# Modern dark colors matching main app
|
|
dark_palette.setColor(QPalette.Window, QColor(32, 32, 36))
|
|
dark_palette.setColor(QPalette.WindowText, QColor(255, 255, 255))
|
|
dark_palette.setColor(QPalette.Base, QColor(18, 18, 20))
|
|
dark_palette.setColor(QPalette.AlternateBase, QColor(42, 42, 46))
|
|
dark_palette.setColor(QPalette.ToolTipBase, QColor(0, 0, 0))
|
|
dark_palette.setColor(QPalette.ToolTipText, QColor(255, 255, 255))
|
|
dark_palette.setColor(QPalette.Text, QColor(255, 255, 255))
|
|
dark_palette.setColor(QPalette.Button, QColor(48, 48, 52))
|
|
dark_palette.setColor(QPalette.ButtonText, QColor(255, 255, 255))
|
|
dark_palette.setColor(QPalette.BrightText, QColor(255, 100, 100))
|
|
dark_palette.setColor(QPalette.Link, QColor(100, 200, 255))
|
|
dark_palette.setColor(QPalette.Highlight, QColor(0, 150, 255))
|
|
dark_palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
|
|
|
|
self.setPalette(dark_palette)
|
|
|
|
# Matching stylesheet
|
|
self.setStyleSheet("""
|
|
QMainWindow {
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 #202024, stop:1 #181820);
|
|
}
|
|
|
|
QGroupBox {
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
color: #ffffff;
|
|
border: 2px solid #00aaff;
|
|
border-radius: 10px;
|
|
margin-top: 20px;
|
|
padding-top: 15px;
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 #2a2a2e, stop:1 #242428);
|
|
}
|
|
QGroupBox::title {
|
|
subcontrol-origin: margin;
|
|
left: 15px;
|
|
padding: 0 10px 0 10px;
|
|
color: #00aaff;
|
|
background-color: #2a2a2e;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
QTabWidget::pane {
|
|
border: 2px solid #00aaff;
|
|
border-radius: 8px;
|
|
background-color: #2a2a2e;
|
|
}
|
|
QTabBar::tab {
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 #404044, stop:1 #353538);
|
|
color: white;
|
|
padding: 12px 20px;
|
|
margin: 2px;
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
min-width: 120px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
QTabBar::tab:selected {
|
|
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
|
stop:0 #0099ff, stop:1 #0077cc);
|
|
color: white;
|
|
border: 2px solid #00aaff;
|
|
}
|
|
""")
|
|
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
|
|
# Set application info
|
|
app.setApplicationName("Master Preset Generator")
|
|
app.setApplicationVersion("1.0")
|
|
app.setOrganizationName("MIDI Arpeggiator")
|
|
|
|
# Create and show main window
|
|
window = MasterPresetGeneratorGUI()
|
|
window.show()
|
|
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|