#!/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())