|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Rebuild src/surge_fx_table.inc: what Wavelength needs to turn a Surge .srgfx effect preset into |
| 3 | +Surge XT Effects' state. |
| 4 | +
|
| 5 | +A .srgfx holds Surge's plain parameter values in storage order; Surge XT Effects stores values |
| 6 | +normalized 0..1 (integers as 0.005 + 0.99 x) in its own slot order. This script |
| 7 | + 1. measures every effect type's parameter ranges and integer-ness by setting the Surge XT synth's |
| 8 | + "FX A1" parameters to 0, 0.5 and 1 and reading back the plain values its patch stores, and |
| 9 | + 2. reads each effect's layout offsets (posy_offset) from Surge's source at TAG, which Surge XT |
| 10 | + Effects sorts its slots by (i * 2 + posy_offset; unused parameters last). |
| 11 | +
|
| 12 | +usage: scripts/surge-fx-table.py [--tag release_xt_1.0.0] [--wavelength build/wavelength] > src/surge_fx_table.inc |
| 13 | +Needs Surge XT (VST3) installed and network access to raw.githubusercontent.com. Runs one plugin |
| 14 | +process at a time (about 3 minutes). |
| 15 | +""" |
| 16 | +import argparse, json, os, re, struct, subprocess, sys, tempfile, urllib.request |
| 17 | + |
| 18 | +ap = argparse.ArgumentParser() |
| 19 | +ap.add_argument('--tag', default='release_xt_1.0.0') |
| 20 | +ap.add_argument('--wavelength', default=os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'build', 'wavelength')) |
| 21 | +a = ap.parse_args() |
| 22 | +W, TMP = a.wavelength, tempfile.mkdtemp(prefix='surgefx-') |
| 23 | +SYN_IDS = [885066848 + k for k in range(10)] + [1667268591, 1667268592] # Surge XT "FX A1 Param 1..12" |
| 24 | +FILES = {1: 'DelayEffect', 2: 'Reverb1Effect', 3: 'PhaserEffect', 4: 'RotarySpeakerEffect', 5: 'DistortionEffect', |
| 25 | + 6: 'ParametricEQ3BandEffect', 7: 'FrequencyShifterEffect', 8: 'ConditionerEffect', 9: 'ChorusEffectImpl', |
| 26 | + 10: 'VocoderEffect', 11: 'Reverb2Effect', 12: 'FlangerEffect', 13: 'RingModulatorEffect', 14: 'airwindows/AirWindowsEffect', |
| 27 | + 15: 'chowdsp/NeuronEffect', 16: 'GraphicEQ11BandEffect', 17: 'ResonatorEffect', 18: 'chowdsp/CHOWEffect', |
| 28 | + 19: 'chowdsp/ExciterEffect', 20: 'BBDEnsembleEffect', 21: 'CombulatorEffect', 22: 'NimbusEffect', 23: 'chowdsp/TapeEffect', |
| 29 | + 24: 'TreemonsterEffect', 25: 'WaveShaperEffect', 26: 'MSToolEffect', 27: 'chowdsp/SpringReverbEffect'} |
| 30 | + |
| 31 | +def run(*args): return subprocess.run([W, *args], capture_output=True, text=True) |
| 32 | +def comp(path): |
| 33 | + d = open(path, 'rb').read(); lo = struct.unpack('<q', d[40:48])[0]; n = struct.unpack('<i', d[lo+4:lo+8])[0] |
| 34 | + for i in range(n): |
| 35 | + e = d[lo+8+i*20:lo+28+i*20]; off, sz = struct.unpack('<qq', e[4:]) |
| 36 | + if e[:4] == b'Comp': return d[off:off+sz] |
| 37 | +def split(c): # Surge patch: "sub3" + u32 xml size + 6 u32 wavetable sizes, xml, wavetables |
| 38 | + xs = struct.unpack('<I', c[4:8])[0]; return c[:32], c[32:32+xs].rstrip(b'\0').decode(), c[32+xs:] |
| 39 | +def join(h, x, t): |
| 40 | + b = x.encode() + b'\0'; h = bytearray(h); h[4:8] = struct.pack('<I', len(b)); return bytes(h) + b + t |
| 41 | + |
| 42 | +run('state', 'save', 'vst3:Surge XT', '--out', TMP + '/base.vstpreset') |
| 43 | +BASE = comp(TMP + '/base.vstpreset') |
| 44 | +def patch(T): |
| 45 | + h, x, t = split(BASE) |
| 46 | + x = re.sub(r'<fx1_(type|p\d+)[^>]*/>', '', x).replace('</parameters>', '<fx1_type type="0" value="%d" /></parameters>' % T) |
| 47 | + return join(h, x, t) |
| 48 | +def synth(T, v): |
| 49 | + open(TMP + '/in.bin', 'wb').write(patch(T)) |
| 50 | + args = ['state', 'save', 'vst3:Surge XT', '--state', TMP + '/in.bin', '--format', 'raw', '--out', TMP + '/out.vstpreset'] |
| 51 | + for k in SYN_IDS: args += ['--set', '#%d=%r' % (k, v)] |
| 52 | + run(*args) |
| 53 | + x = split(comp(TMP + '/out.vstpreset'))[1] |
| 54 | + return [re.search(r'<fx1_p%d type="(\d)" value="([^"]*)"' % i, x) for i in range(12)] |
| 55 | +def names(T): |
| 56 | + open(TMP + '/in.bin', 'wb').write(patch(T)) |
| 57 | + ps = {p['id']: p['name'] for p in json.loads(run('params', 'vst3:Surge XT', '--state', TMP + '/in.bin', '--format', 'raw', '--all', '--json').stdout)['params']} |
| 58 | + return [ps.get(i, '').replace('FX A1 ', '') for i in SYN_IDS] |
| 59 | +def source(path): |
| 60 | + t = '' |
| 61 | + for ext in ('.h', '.cpp'): |
| 62 | + try: t += urllib.request.urlopen('https://raw.githubusercontent.com/surge-synthesizer/surge/%s/src/common/dsp/effects/%s%s' % (a.tag, path, ext)).read().decode() |
| 63 | + except Exception: pass |
| 64 | + if path == 'ChorusEffectImpl': t += source('ChorusEffect') |
| 65 | + t = re.sub(r'/\*.*?\*/', '', t, flags=re.S); return re.sub(r'//[^\n]*', '', t) |
| 66 | +def enum_of(t, members): |
| 67 | + for m in re.finditer(r'enum\s+\w*\s*\{([^}]*)\}', t): |
| 68 | + ms = [x.strip().split('=')[0].strip() for x in m.group(1).split(',') if x.strip()] |
| 69 | + if all(n in ms for n in members): return {n: i for i, n in enumerate(ms)} |
| 70 | + |
| 71 | +print('// Generated by scripts/surge-fx-table.py (--tag %s): each Surge effect type\'s parameters in storage' % a.tag) |
| 72 | +print('// order (the order .srgfx files use) as {integer, min, max}, and remap[slot] = the storage parameter') |
| 73 | +print('// behind Surge XT Effects\' parameter slot. Unused parameters have min == max. Ranges were measured') |
| 74 | +print('// through the Surge XT synth; the slot order is Surge XT Effects\' sort of each effect\'s layout offsets.') |
| 75 | +f = lambda v: repr(float(v)) + 'f' |
| 76 | +for T, path in FILES.items(): |
| 77 | + nm, lo, hi = names(T), synth(T, 0.0), synth(T, 1.0) |
| 78 | + unused = [] if T == 14 else [i for i in range(12) if not lo[i] or re.fullmatch(r'Param \d+|', nm[i])] |
| 79 | + t = source(path) |
| 80 | + asg = re.findall(r'p\[(\w+)\]\.posy_offset\s*=\s*(-?\w+)\s*;', t) |
| 81 | + en = enum_of(t, [n for n, _ in asg]) if asg and not any(n.isdigit() or n == 'i' for n, _ in asg) else None |
| 82 | + key = {en[n]: en[n] * 2 + int(v) for n, v in asg if int(v) != 0} if T != 14 and en and all(v.lstrip('-').isdigit() for _, v in asg) \ |
| 83 | + else {i: i for i in range(12)} # loops and increasing offsets keep storage order |
| 84 | + used = [i for i in range(12) if i in key and i not in unused] |
| 85 | + remap = sorted(used, key=lambda i: (key[i], i)) + [i for i in range(12) if i not in used] |
| 86 | + ps = [] |
| 87 | + for i in range(12): |
| 88 | + if T == 14 and i > 0: ps.append('{0, 0.0f, 1.0f}') # Airwindows: the chosen effect's parameters are 0..1 |
| 89 | + elif i in unused: ps.append('{0, 0.0f, 0.0f}') |
| 90 | + else: ps.append('{%d, %s, %s}' % (lo[i].group(1) == '0', f(lo[i].group(2)), f(hi[i].group(2)))) |
| 91 | + print('{%d, {%s},\n {%s}},' % (T, ', '.join(map(str, remap)), ', '.join(ps))) |
| 92 | + print('type', T, path, file=sys.stderr, flush=True) |
0 commit comments