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
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())
|