570 lines
20 KiB
Python
570 lines
20 KiB
Python
#!/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()
|