#!/usr/bin/env python3 """Build factory instrument presets from Virtual Playing Orchestra 3 samples. Usage: python3 scripts/build_instruments.py Converts WAV samples to MP3 and generates MultiSampler JSON presets. """ import json import os import re import subprocess import sys from pathlib import Path VPO = Path.home() / "Downloads" / "Virtual-Playing-Orchestra3" / "libs" INSTRUMENTS_DIR = Path(__file__).parent.parent / "src" / "assets" / "instruments" # Note name to semitone offset (within octave) NOTE_MAP = { 'c': 0, 'c#': 1, 'db': 1, 'd': 2, 'd#': 3, 'eb': 3, 'e': 4, 'f': 5, 'f#': 6, 'gb': 6, 'g': 7, 'g#': 8, 'ab': 8, 'a': 9, 'a#': 10, 'bb': 10, 'b': 11, } def note_to_midi(note_name: str, octave: int) -> int: """Convert note name + octave to MIDI number. C4 = 60.""" semitone = NOTE_MAP[note_name.lower()] return (octave + 1) * 12 + semitone def parse_sso_filename(filename: str) -> dict | None: """Parse SSO-style: instrument-sus-note-PB-loop.wav (e.g. 1st-violins-sus-a#3.wav) Also handles flats: oboe-a#3, basses-sus-d#2, etc. """ m = re.search(r'([a-g][#b]?)(\d+)', filename.lower()) if not m: return None note, octave = m.group(1), int(m.group(2)) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note.upper()}{octave}"} def parse_nbo_filename(filename: str) -> dict | None: """Parse NBO-style: octave_note.wav (e.g. 3_Bb-PB-loop.wav)""" m = re.match(r'(\d+)_([A-Ga-g][b#]?)', filename) if not m: return None octave, note = int(m.group(1)), m.group(2) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note}{octave}"} def parse_nbo_with_dynamics(filename: str) -> dict | None: """Parse NBO2-style with dynamics: octave_note_p.wav or octave_note.wav""" m = re.match(r'(\d+)_([A-Ga-g][b#]?)(?:_(p|f|mf|ff))?', filename) if not m: return None octave, note = int(m.group(1)), m.group(2) dynamic = m.group(3) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note}{octave}", 'dynamic': dynamic} def parse_mw_viola_filename(filename: str) -> dict | None: """Parse MW-style: Violas_note.wav (e.g. Violas_c4.wav, Violas_d#3.wav)""" m = re.search(r'_([a-g][#b]?)(\d+)\.wav', filename.lower()) if not m: return None note, octave = m.group(1), int(m.group(2)) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note.upper()}{octave}"} def parse_mw_horn_filename(filename: str) -> dict | None: """Parse MW horn: horns-sus-ff-note-PB-loop.wav or horns-sus-mp-note-PB-loop.wav""" # Extract dynamics marker (ff, mp) from filename dyn_match = re.search(r'-(ff|mp|mf|p|pp)-', filename.lower()) dynamic = dyn_match.group(1) if dyn_match else None # Extract note m = re.search(r'([a-g][#b]?)(\d+)', filename.lower()) if not m: return None note, octave = m.group(1), int(m.group(2)) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note.upper()}{octave}", 'dynamic': dynamic} def parse_vsco_harp_filename(filename: str) -> dict | None: """Parse VSCO harp: KSHarp_Note_dyn.wav (e.g. KSHarp_A4_mf.wav)""" m = re.search(r'KSHarp_([A-G][b#]?)(\d+)', filename) if not m: return None note, octave = m.group(1), int(m.group(2)) midi = note_to_midi(note, octave) return {'midi': midi, 'note': f"{note}{octave}"} def convert_wav_to_mp3(wav_path: Path, mp3_path: Path, bitrate: str = '192k'): """Convert WAV to MP3 using ffmpeg with peak normalization.""" mp3_path.parent.mkdir(parents=True, exist_ok=True) if mp3_path.exists(): return # Skip if already converted # Peak-normalize to -1dBFS so all samples have consistent max level. # Using dynaudnorm with very gentle settings to avoid changing the # character of the sound — just brings everything to the same peak level. subprocess.run([ 'ffmpeg', '-i', str(wav_path), '-af', 'loudnorm=I=-16:TP=-1:LRA=11', '-ar', '44100', '-ab', bitrate, '-y', '-loglevel', 'error', str(mp3_path) ], check=True) def compute_key_ranges(layers: list[dict]) -> list[dict]: """Compute key_min/key_max for each layer by splitting at midpoints between adjacent root notes.""" if not layers: return layers layers.sort(key=lambda l: l['root_key']) for i, layer in enumerate(layers): if i == 0: layer['key_min'] = 0 else: midpoint = (layers[i-1]['root_key'] + layer['root_key']) // 2 + 1 layer['key_min'] = midpoint layers[i-1]['key_max'] = midpoint - 1 if i == len(layers) - 1: layer['key_max'] = 127 return layers def make_preset(name: str, description: str, tags: list[str], layers: list[dict], attack: float = 0.01, release: float = 0.3) -> dict: """Generate a complete instrument preset JSON.""" return { "metadata": { "name": name, "description": description, "author": "Virtual Playing Orchestra 3", "version": 1, "tags": tags }, "midi_targets": [0], "output_node": 2, "nodes": [ { "id": 0, "node_type": "MidiInput", "name": "MIDI In", "parameters": {}, "position": [100.0, 100.0] }, { "id": 1, "node_type": "MultiSampler", "name": f"{name} Sampler", "parameters": { "0": 1.0, # gain "1": attack, # attack "2": release, # release "3": 0.0 # transpose }, "sample_data": { "type": "multi_sampler", "layers": layers }, "position": [350.0, 0.0] }, { "id": 2, "node_type": "AudioOutput", "name": "Out", "parameters": {}, "position": [700.0, 100.0] } ], "connections": [ {"from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0}, {"from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0} ] } def build_simple_instrument(name: str, description: str, tags: list[str], source_dir: Path, output_subdir: str, filename_filter=None, parser=parse_sso_filename, attack: float = 0.01, release: float = 0.3, loop: bool = False): """Build a single-velocity instrument from a directory of WAV files.""" out_dir = INSTRUMENTS_DIR / output_subdir samples_dir = out_dir / "samples" samples_dir.mkdir(parents=True, exist_ok=True) layers = [] wav_files = sorted(source_dir.glob("*.wav")) for wav in wav_files: if filename_filter and not filename_filter(wav.name): continue parsed = parser(wav.name) if not parsed: print(f" WARNING: Could not parse {wav.name}, skipping") continue mp3_name = f"{parsed['note']}.mp3" mp3_path = samples_dir / mp3_name print(f" Converting {wav.name} -> {mp3_name} (MIDI {parsed['midi']})") convert_wav_to_mp3(wav, mp3_path) layer = { "file_path": f"samples/{mp3_name}", "root_key": parsed['midi'], "velocity_min": 0, "velocity_max": 127, } if loop: layer["loop_mode"] = "continuous" layers.append(layer) layers = compute_key_ranges(layers) preset = make_preset(name, description, tags, layers, attack, release) preset_path = out_dir / f"{output_subdir.split('/')[-1]}.json" with open(preset_path, 'w') as f: json.dump(preset, f, indent=2) print(f" -> Wrote {preset_path} ({len(layers)} layers)") return layers def build_dynamics_instrument(name: str, description: str, tags: list[str], source_dir: Path, output_subdir: str, filename_filter=None, parser=parse_nbo_with_dynamics, attack: float = 0.01, release: float = 0.3, loop: bool = False): """Build an instrument with velocity layers from dynamics markings.""" out_dir = INSTRUMENTS_DIR / output_subdir samples_dir = out_dir / "samples" samples_dir.mkdir(parents=True, exist_ok=True) # Group samples by dynamics level # Map dynamics markings to velocity ranges (soft to loud) DYNAMICS_ORDER = ['pp', 'p', 'mp', 'mf', 'f', 'ff'] dynamics_groups: dict[str | None, list[dict]] = {} wav_files = sorted(source_dir.glob("*.wav")) for wav in wav_files: if filename_filter and not filename_filter(wav.name): continue parsed = parser(wav.name) if not parsed: print(f" WARNING: Could not parse {wav.name}, skipping") continue dyn = parsed.get('dynamic') suffix = f"_{dyn}" if dyn else "" mp3_name = f"{parsed['note']}{suffix}.mp3" mp3_path = samples_dir / mp3_name print(f" Converting {wav.name} -> {mp3_name} (MIDI {parsed['midi']}, dyn={dyn})") convert_wav_to_mp3(wav, mp3_path) layer = { "file_path": f"samples/{mp3_name}", "root_key": parsed['midi'], } if loop: layer["loop_mode"] = "continuous" dynamics_groups.setdefault(dyn, []).append(layer) # Determine velocity ranges based on how many dynamics levels exist # Treat None (unmarked) as forte — it's the "normal" dynamic dyn_keys = sorted(dynamics_groups.keys(), key=lambda d: DYNAMICS_ORDER.index(d) if d and d in DYNAMICS_ORDER else (DYNAMICS_ORDER.index('f') if d is None else 3)) if len(dyn_keys) == 1: # Only one dynamics level — full velocity for layer in dynamics_groups[dyn_keys[0]]: layer["velocity_min"] = 0 layer["velocity_max"] = 127 else: num_levels = len(dyn_keys) vel_step = 128 // num_levels for i, dyn in enumerate(dyn_keys): vel_min = i * vel_step vel_max = (i + 1) * vel_step - 1 if i < num_levels - 1 else 127 for layer in dynamics_groups[dyn]: layer["velocity_min"] = vel_min layer["velocity_max"] = vel_max # Compute key ranges separately for each velocity group all_layers = [] for dyn, group in dynamics_groups.items(): group = compute_key_ranges(group) all_layers.extend(group) preset = make_preset(name, description, tags, all_layers, attack, release) preset_path = out_dir / f"{output_subdir.split('/')[-1]}.json" with open(preset_path, 'w') as f: json.dump(preset, f, indent=2) dyn_summary = ", ".join(f"{k or 'default'}: {len(v)}" for k, v in dynamics_groups.items()) print(f" -> Wrote {preset_path} ({len(all_layers)} layers: {dyn_summary})") return all_layers def build_combined_instrument(name: str, description: str, tags: list[str], component_dirs: list[str], output_subdir: str, attack: float = 0.01, release: float = 0.3): """Build a combined instrument that references samples from component instruments. component_dirs: list of output_subdir paths for component instruments, ordered low to high pitch. Splits the keyboard range across them. """ out_dir = INSTRUMENTS_DIR / output_subdir out_dir.mkdir(parents=True, exist_ok=True) # Load each component's preset to get its layers all_component_layers = [] for comp_dir in component_dirs: comp_path = INSTRUMENTS_DIR / comp_dir json_files = list(comp_path.glob("*.json")) if not json_files: print(f" WARNING: No preset found in {comp_dir}") continue with open(json_files[0]) as f: comp_preset = json.load(f) comp_layers = comp_preset["nodes"][1]["sample_data"]["layers"] # Adjust file paths to be relative from the combined instrument dir rel_prefix = os.path.relpath(comp_path, out_dir) for layer in comp_layers: layer["file_path"] = f"{rel_prefix}/{layer['file_path']}" all_component_layers.extend(comp_layers) # Re-sort by root key and recompute ranges across all layers # Group by velocity range to handle dynamics separately vel_groups = {} for layer in all_component_layers: vel_key = (layer["velocity_min"], layer["velocity_max"]) vel_groups.setdefault(vel_key, []).append(layer) final_layers = [] for vel_key, group in vel_groups.items(): group = compute_key_ranges(group) # Preserve the original velocity range for layer in group: layer["velocity_min"] = vel_key[0] layer["velocity_max"] = vel_key[1] final_layers.extend(group) preset = make_preset(name, description, tags, final_layers, attack, release) preset_path = out_dir / f"{output_subdir.split('/')[-1]}.json" with open(preset_path, 'w') as f: json.dump(preset, f, indent=2) print(f" -> Wrote {preset_path} ({len(final_layers)} layers from {len(component_dirs)} components)") def main(): print("=== Building Lightningbeam Factory Instruments from VPO3 ===\n") if not VPO.exists(): print(f"ERROR: VPO3 not found at {VPO}") sys.exit(1) # --- STRINGS --- print("\n[1/14] Violin Section (SSO 1st Violins sustain)") build_simple_instrument( "Violin Section", "Orchestral violin section with sustained bowing", ["strings", "violin", "section", "orchestral"], VPO / "SSO" / "Samples" / "1st Violins", "strings/violin-section", filename_filter=lambda f: 'sus' in f.lower(), parser=parse_sso_filename, attack=0.05, release=0.4, loop=True, ) print("\n[2/14] Viola Section (Mattias-Westlund)") build_simple_instrument( "Viola Section", "Orchestral viola section with sustained bowing", ["strings", "viola", "section", "orchestral"], VPO / "Mattias-Westlund" / "ViolaSect" / "Samples", "strings/viola-section", parser=parse_mw_viola_filename, attack=0.05, release=0.4, loop=True, ) print("\n[3/14] Cello Section (NBO sustain)") build_simple_instrument( "Cello Section", "Orchestral cello section with sustained bowing", ["strings", "cello", "section", "orchestral"], VPO / "NoBudgetOrch" / "CelloSect" / "Sustain", "strings/cello-section", parser=parse_nbo_filename, attack=0.05, release=0.4, loop=True, ) print("\n[4/14] Bass Section (SSO sustain)") build_simple_instrument( "Bass Section", "Orchestral double bass section with sustained bowing", ["strings", "bass", "contrabass", "section", "orchestral"], VPO / "SSO" / "Samples" / "Basses", "strings/bass-section", filename_filter=lambda f: 'sus' in f.lower(), parser=parse_sso_filename, attack=0.08, release=0.5, loop=True, ) print("\n[5/14] Harp (VSCO2-CE)") build_simple_instrument( "Harp", "Concert harp", ["strings", "harp", "orchestral"], VPO / "VSCO2-CE" / "Strings" / "Harp", "strings/harp", parser=parse_vsco_harp_filename, attack=0.001, release=0.8, ) # --- WOODWINDS --- print("\n[6/14] Flute Section (NBO)") build_simple_instrument( "Flute", "Orchestral flute section", ["woodwinds", "flute", "section", "orchestral"], VPO / "NoBudgetOrch" / "FluteSect", "woodwinds/flute", parser=parse_nbo_filename, attack=0.03, release=0.3, loop=True, ) print("\n[7/14] Oboe (SSO solo)") build_simple_instrument( "Oboe", "Solo oboe", ["woodwinds", "oboe", "solo", "orchestral"], VPO / "SSO" / "Samples" / "Oboe", "woodwinds/oboe", filename_filter=lambda f: f.endswith('.wav') and 'readme' not in f.lower(), parser=parse_sso_filename, attack=0.02, release=0.25, loop=True, ) print("\n[8/14] Clarinet Section (NBO)") build_simple_instrument( "Clarinet", "Orchestral clarinet section", ["woodwinds", "clarinet", "section", "orchestral"], VPO / "NoBudgetOrch" / "ClarinetSect" / "Sustain", "woodwinds/clarinet", parser=parse_nbo_filename, attack=0.02, release=0.25, loop=True, ) print("\n[9/14] Bassoon (SSO)") build_simple_instrument( "Bassoon", "Solo bassoon", ["woodwinds", "bassoon", "solo", "orchestral"], VPO / "SSO" / "Samples" / "Bassoon", "woodwinds/bassoon", parser=parse_sso_filename, attack=0.03, release=0.3, loop=True, ) # --- BRASS --- print("\n[10/14] Horn Section (Mattias-Westlund, ff + mp dynamics)") build_dynamics_instrument( "Horn Section", "French horn section with forte and mezzo-piano dynamics", ["brass", "horn", "french horn", "section", "orchestral"], VPO / "Mattias-Westlund" / "Horns" / "Samples", "brass/horn-section", parser=parse_mw_horn_filename, attack=0.04, release=0.4, loop=True, ) print("\n[11/14] Trumpet Section (NBO2 with dynamics)") build_dynamics_instrument( "Trumpet Section", "Orchestral trumpet section with piano and forte dynamics", ["brass", "trumpet", "section", "orchestral"], VPO / "NoBudgetOrch2" / "Trumpet" / "TrumpetSect" / "Sustain", "brass/trumpet-section", attack=0.02, release=0.3, loop=True, ) print("\n[12/14] Trombone Section (NBO2 with dynamics)") build_dynamics_instrument( "Trombone Section", "Orchestral trombone section with piano and forte dynamics", ["brass", "trombone", "section", "orchestral"], VPO / "NoBudgetOrch2" / "Trombone" / "TromboneSect" / "Sustain", "brass/trombone-section", attack=0.03, release=0.35, loop=True, ) print("\n[13/14] Tuba (SSO sustain)") build_simple_instrument( "Tuba", "Orchestral tuba", ["brass", "tuba", "orchestral"], VPO / "SSO" / "Samples" / "Tuba", "brass/tuba", filename_filter=lambda f: 'sus' in f.lower(), parser=parse_sso_filename, attack=0.04, release=0.4, loop=True, ) # --- PERCUSSION --- print("\n[14/14] Timpani (NBO)") build_simple_instrument( "Timpani", "Orchestral timpani", ["percussion", "timpani", "orchestral"], VPO / "NoBudgetOrch" / "Timpani", "orchestral/timpani", parser=lambda f: parse_sso_filename(f), # Note-octave format like A2-PB.wav attack=0.001, release=1.5, ) # --- COMBINED INSTRUMENTS --- print("\n[Combined] Strings") build_combined_instrument( "Strings", "Full string section — auto-selects violin, viola, cello, or bass by pitch range", ["strings", "section", "orchestral", "combined"], [ "strings/bass-section", "strings/cello-section", "strings/viola-section", "strings/violin-section", ], "strings/strings-combined", attack=0.05, release=0.4, ) print("\n[Combined] Woodwinds") build_combined_instrument( "Woodwinds", "Full woodwind section — auto-selects bassoon, clarinet, oboe, or flute by pitch range", ["woodwinds", "section", "orchestral", "combined"], [ "woodwinds/bassoon", "woodwinds/clarinet", "woodwinds/oboe", "woodwinds/flute", ], "woodwinds/woodwinds-combined", attack=0.03, release=0.3, ) print("\n[Combined] Brass") build_combined_instrument( "Brass", "Full brass section — auto-selects tuba, trombone, horn, or trumpet by pitch range", ["brass", "section", "orchestral", "combined"], [ "brass/tuba", "brass/trombone-section", "brass/horn-section", "brass/trumpet-section", ], "brass/brass-combined", attack=0.03, release=0.35, ) print("\n=== Done! ===") if __name__ == '__main__': main()