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.
 

439 lines
18 KiB

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