diff --git a/master_preset_generator.py b/master_preset_generator.py new file mode 100644 index 0000000..89d0d1a --- /dev/null +++ b/master_preset_generator.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +Master Preset Generator for MIDI Arpeggiator + +Creates master preset files with subtle, musical progressions based on a template preset. +Designed for live performance use - maintains consistent synth count and hardware setup. +""" + +import json +import copy +import argparse +import os +from datetime import datetime +from typing import Dict, List, Any, Tuple +import math + + +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 + + def get_compatible_scale(self, current_scale: str) -> str: + """Get a scale that works harmonically with current scale""" + if current_scale in self.scale_relationships: + compatible = self.scale_relationships[current_scale] + # Prefer staying in same scale family, but allow gentle changes + return compatible[0] if len(compatible) > 1 else current_scale + return current_scale + + +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}, + } + + # Note speeds - ONLY slow to medium speeds for live performance + self.note_speeds = ["1/1", "1/2", "1/4"] + + # Pattern types for variety + self.pattern_types = ["up", "down", "up_down", "down_up", "chord"] + + # Scales for modal journeys + self.scales = ["major", "minor", "mixolydian", "dorian", "lydian", "phrygian", "pentatonic"] + + # Delay timings + self.delay_timings = ["1/4", "1/2", "1/1", "2/1", "1/4T", "1/2T", "2/1T"] + + 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 + + def apply_subtle_change(self, preset: Dict[str, Any], param_path: str, direction: int = 1, magnitude: float = 1.0) -> None: + """Apply a subtle change to a parameter""" + path_parts = param_path.split('.') + + # Navigate to the parameter + current = preset + for part in path_parts[:-1]: + if part in current: + current = current[part] + else: + return + + param_name = path_parts[-1] + if param_name not in current: + return + + current_value = current[param_name] + + # Apply change based on parameter type + if param_name in self.parameter_ranges: + range_info = self.parameter_ranges[param_name] + step = range_info["step"] * direction * magnitude + new_value = current_value + step + current[param_name] = self.clamp_parameter(param_name, new_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[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 = 25 # +/- 25 BPM + arp["tempo"] = base_tempo + (tempo_range * factor * 0.6) # Subtle tempo increase + + # Velocity progression + base_velocity = base_preset["arpeggiator"]["velocity"] + velocity_range = 20 + arp["velocity"] = int(base_velocity + (velocity_range * factor * 0.8)) + + # Gate progression (tighter at peak) + base_gate = base_preset["arpeggiator"]["gate"] + gate_change = 0.15 * factor + 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.15 * factor + arp["swing"] = template.clamp_parameter("swing", base_swing + swing_change) + + # Pattern length progression + base_length = base_preset["arpeggiator"]["user_pattern_length"] + length_change = int(3 * factor) # Add up to 3 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.2 * factor + arp["delay_fade"] = template.clamp_parameter("delay_fade", base_fade + fade_change) + + # Add some variety every few presets + if i > 0 and i % 4 == 0: + # Subtle scale note shift + current_start = arp.get("scale_note_start", 0) + new_start = (current_start + 1) % 7 + arp["scale_note_start"] = new_start + + if i > 0 and i % 6 == 0: + # Gentle pattern type change + pattern_types = ["up", "down", "up_down"] + current_pattern = arp.get("pattern_type", "up") + if current_pattern in pattern_types: + current_idx = pattern_types.index(current_pattern) + new_idx = (current_idx + 1) % len(pattern_types) + arp["pattern_type"] = pattern_types[new_idx] + + # 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[Dict[str, Any]]: + """Progress through related musical modes""" + presets = [] + current_scale = base_preset["arpeggiator"].get("scale", "major") + + # Plan the modal journey + scale_sequence = ["major", "mixolydian", "dorian", "minor", "phrygian", "minor", "dorian", "major"] + + for i in range(count): + preset = copy.deepcopy(base_preset) + arp = preset["arpeggiator"] + + # Progress through scales + scale_idx = int((i / count) * len(scale_sequence)) + scale_idx = min(scale_idx, len(scale_sequence) - 1) + arp["scale"] = scale_sequence[scale_idx] + + # Subtle root note movement every few presets + if i > 0 and i % 3 == 0: + current_root = arp.get("root_note", 60) + new_root = musical_logic.get_next_root_note(current_root, "circle_of_fifths") + # Keep in reasonable range + if 48 <= new_root <= 72: + arp["root_note"] = new_root + + # Other subtle progressions + factor = i / (count - 1) if count > 1 else 0 + + # Gentle tempo drift + base_tempo = base_preset["arpeggiator"]["tempo"] + tempo_drift = math.sin(factor * math.pi * 2) * 8 # +/- 8 BPM sine wave + arp["tempo"] = base_tempo + tempo_drift + + # Pattern length variation + base_length = base_preset["arpeggiator"]["user_pattern_length"] + if i % 5 == 0 and i > 0: + length_options = [base_length, base_length + 1, base_length - 1] + arp["user_pattern_length"] = template.clamp_parameter("user_pattern_length", + length_options[i % len(length_options)]) + + 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 < 80 or tempo > 140: + errors.append(f"Tempo {tempo} outside live performance range (80-140)") + + velocity = arp.get("velocity", 100) + if velocity < 1 or velocity > 127: + errors.append(f"Velocity {velocity} outside MIDI range (1-127)") + + gate = arp.get("gate", 1.0) + if gate < 0.1 or gate > 2.0: + errors.append(f"Gate {gate} outside reasonable range (0.1-2.0)") + + # Check note speed is performance-friendly + note_speed = arp.get("note_speed", "1/4") + if note_speed not in ["1/1", "1/2", "1/4"]: + errors.append(f"Note speed {note_speed} too fast for live performance") + + return len(errors) == 0, errors + + def check_progression_smoothness(self, preset_list: List[Tuple[str, Dict[str, Any]]]) -> List[str]: + """Check for jarring jumps between adjacent presets""" + warnings = [] + + for i in range(1, len(preset_list)): + prev_name, prev_preset = preset_list[i-1] + curr_name, curr_preset = preset_list[i] + + prev_arp = prev_preset["arpeggiator"] + curr_arp = curr_preset["arpeggiator"] + + # Check tempo jumps + tempo_diff = abs(curr_arp["tempo"] - prev_arp["tempo"]) + if tempo_diff > 15: + warnings.append(f"Large tempo jump ({tempo_diff:.1f} BPM) between {prev_name} and {curr_name}") + + # Check velocity jumps + vel_diff = abs(curr_arp["velocity"] - prev_arp["velocity"]) + if vel_diff > 25: + warnings.append(f"Large velocity jump ({vel_diff}) between {prev_name} and {curr_name}") + + return warnings + + +class MasterPresetGenerator: + """Main generator class""" + + def __init__(self, base_preset_path: str): + with open(base_preset_path, 'r') as f: + self.base_preset = json.load(f) + + self.musical_logic = MusicalLogic() + 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""" + + print(f"Generating master preset '{name}' with {count} presets using '{strategy}' strategy...") + + # 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}") + + # Validate all presets + print("Validating generated presets...") + all_valid = True + for preset_name, preset in preset_list: + valid, errors = self.validator.validate_preset(preset) + if not valid: + print(f" WARNING: {preset_name} has validation errors: {errors}") + all_valid = False + + # Check progression smoothness + warnings = self.validator.check_progression_smoothness(preset_list) + if warnings: + print(" Progression warnings:") + for warning in warnings: + print(f" - {warning}") + + # 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() + master_preset["presets"][preset_name] = preset + master_preset["preset_group"]["presets"].append(preset_name) + + print(f"āœ“ Generated {len(preset_list)} presets successfully") + print(f"āœ“ Synth count locked at {self.base_preset['channels']['active_synth_count']} channels") + + return master_preset + + def save_master_preset(self, master_preset: Dict[str, Any], output_path: str): + """Save master preset to file""" + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + with open(output_path, 'w') as f: + json.dump(master_preset, f, indent=2) + + print(f"āœ“ Saved master preset to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Generate master preset files for MIDI Arpeggiator") + parser.add_argument("--base-preset", "-b", required=True, help="Path to base preset JSON file") + parser.add_argument("--name", "-n", required=True, help="Name for the master preset") + parser.add_argument("--count", "-c", type=int, default=16, help="Number of presets to generate") + parser.add_argument("--strategy", "-s", choices=["build_and_release", "modal_journey"], + default="build_and_release", help="Generation strategy") + parser.add_argument("--loop-count", "-l", type=int, default=4, help="Loop count for preset group") + parser.add_argument("--output", "-o", help="Output directory (default: master_files/)") + + args = parser.parse_args() + + # Set default output path + if not args.output: + args.output = "master_files/" + + output_path = os.path.join(args.output, f"{args.name}.json") + + try: + # Generate master preset + generator = MasterPresetGenerator(args.base_preset) + master_preset = generator.generate_master_preset( + args.name, args.count, args.strategy, args.loop_count + ) + + # Save to file + generator.save_master_preset(master_preset, output_path) + + print(f"\nšŸŽµ Master preset '{args.name}' generated successfully!") + print(f" Base: {args.base_preset}") + print(f" Strategy: {args.strategy}") + print(f" Presets: {args.count}") + print(f" Output: {output_path}") + + except Exception as e: + print(f"āŒ Error generating master preset: {e}") + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) \ No newline at end of file diff --git a/master_preset_generator_gui.py b/master_preset_generator_gui.py new file mode 100644 index 0000000..0c44bdb --- /dev/null +++ b/master_preset_generator_gui.py @@ -0,0 +1,1005 @@ +#!/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() \ No newline at end of file