Skip to content

Commit e246372

Browse files
committed
📦 NEW: Surge XT Effects .srgfx presets by name
A .srgfx stores Surge's plain values in storage order; Surge XT Effects stores them normalized (integers 0.005 + 0.99x) in slots sorted by each effect's layout offsets. src/surge_fx_table.inc holds ranges measured through the Surge XT synth and the slot order from Surge's source (scripts/surge-fx-table.py rebuilds it). 245 of 266 presets round-trip exactly through the plugin; the rest are Airwindows zero-valued integers and the deactivated flag, which Surge XT Effects 1.0 ignores. Delay presets echo at exactly 2^time seconds.
1 parent 8bf4ee7 commit e246372

9 files changed

Lines changed: 277 additions & 6 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ Stems are written **after** the track's `fx` and **before** its fader, so fader
243243
- Plugin tracks render in worker processes, several at once: a crashing plugin costs its track (retried, then listed in `failedTracks`), not the song.
244244
- SoundFonts and SFZ play without LFOs (vibrato, tremolo) or pitch envelopes. A job has one time signature: a score's time signature changes keep their timing but not their bar numbers.
245245
- A plugin whose sound depends on its own GUI or licence dialog may render its demo/default. An unlicensed plugin that opens a registration window on every load (on macOS the render kills that worker within about 2 s and the track fails saying so) should be blocked: `wavelength plugins --block <plugin> --reason "..."`; `plugins` marks it BLOCKED and render, params, presets and audition refuse it. An effect that only changes the level gets a warning; a state or preset that changes no parameter gets a warning too.
246-
- Presets by name come from CLAP preset discovery, VST3 program lists, and preset files in the plugin's preset folders: Serum 2 `.SerumPreset`, Odin2 `.odin`, u-he `.h2p` (Zebra2, Zebralette, TripleCheese, TyrellN6), Surge XT, Surge 1.x and OB-Xf `.fxp`, every program of OB-Xd's `.fxb` banks, VST2 program lists (Helm, Monique), audjoo Helix `.hxp`, Echobode `.echobode` and Permut8 `.p8bank` programs, MeldaProduction bank presets (MTurboReverb, MCompressor, ...), Serum 2 FX `.SerumFX`/`.SerumFXRack` (`"Reverb/Big Retro Mod Verb"`), Kilohearts kHs effect presets, Pendulate `.tide`, and any `.vstpreset` under `/Library/Audio/Presets/<Vendor>/<Plugin>`, every DX7 cartridge voice for Dexed, and NKS presets (DUNE 3, BBC Symphony Orchestra, SynthMaster, u-he `.nksfx` effects such as Zebrify, Reaktor Blocks `.nksr`), Synplant, Cherry Audio, Guitar Rig racks and Microtonic kits and drums (`"AC BD Back#3"` puts a drum on channel 3). `wavelength presets
246+
- Presets by name come from CLAP preset discovery, VST3 program lists, and preset files in the plugin's preset folders: Serum 2 `.SerumPreset`, Odin2 `.odin`, u-he `.h2p` (Zebra2, Zebralette, TripleCheese, TyrellN6), Surge XT, Surge 1.x and OB-Xf `.fxp`, every program of OB-Xd's `.fxb` banks, VST2 program lists (Helm, Monique), audjoo Helix `.hxp`, Echobode `.echobode` and Permut8 `.p8bank` programs, MeldaProduction bank presets (MTurboReverb, MCompressor, ...), Serum 2 FX `.SerumFX`/`.SerumFXRack` (`"Reverb/Big Retro Mod Verb"`), Kilohearts kHs effect presets, Pendulate `.tide`, Surge XT Effects `.srgfx`, and any `.vstpreset` under `/Library/Audio/Presets/<Vendor>/<Plugin>`, every DX7 cartridge voice for Dexed, and NKS presets (DUNE 3, BBC Symphony Orchestra, SynthMaster, u-he `.nksfx` effects such as Zebrify, Reaktor Blocks `.nksr`), Synplant, Cherry Audio, Guitar Rig racks and Microtonic kits and drums (`"AC BD Back#3"` puts a drum on channel 3). `wavelength presets
247247
<plugin>` lists them all. Parameters also take the plugin's display text: `"Cutoff": "800 Hz"`.
248248
Presets compiled into plugin binaries (TAL-NoiseMaker, Relica 2) appear after running `scripts/extract-embedded-presets.py` (in the source checkout) once. Analog Lab V, AAS Player (guitars, electric pianos, mallets), MPowerSynth, Soundbox and DecentSampler (including the library folder set in DecentSampler's own settings) also load by name.
249249
- Not loadable headlessly: Kontakt (never pass it an unknown state file: it can hang the render), Komplete Kontrol, ZENOLOGY (needs a Roland Cloud login), Spitfire LABS (encrypted patches; the default patch plays), UVI Workstation.

‎changelog.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ All notable changes to Wavelength. Versions follow semantic versioning.
66

77
### Added
88
- The sampler finds Serum 2's 140 multisamples (plain SFZ instruments) and plays SFZ `amp_velcurve_N` velocity curves, `amp_keytrack`/`amp_keycenter` and `loop_crossfade`; `amp_velcurve_N` was skipped without a warning before. A folder that an SFZ plays its samples from is no longer listed as a kit of its own.
9-
- More presets by name: every program of OB-Xd's `.fxb` banks (`"<bank>.fxb#<n>"` as state), Surge 1.x patches, DecentSampler presets in the library folder its settings name, u-he NKS effect presets (`.nksfx`, Zebrify) and Reaktor Blocks racks (`.nksr`), VST2 program lists (Helm 274, Monique 143; placeholder lists such as "Program 1..n" are skipped), audjoo Helix's 1,302 `.hxp` patches (state format `helix`), Pendulate `.tide` presets (209), Kilohearts snap-in presets (262 across 28 kHs effects, state format `kilohearts`), Serum 2 FX effect presets and racks (`.SerumFX`, `.SerumFXRack`, 218 by name, state format `serumfx`: the file's modules replace the plugin's rack; all 220 factory files load), MeldaProduction presets for every Melda plugin from its `<plugin>.presets` bank (about 3,200 across 42 plugins; MPowerSynth goes from 40 to 1,578: string-valued and length-prefixed entries in Melda banks are read now), Echobode's 212 `.echobode` patches (`echobode`), every program of Permut8's `.p8bank` banks (`permut8`, `"<bank>.p8bank#<0-29>"`), and TyrellN6 `.h2p` presets (older u-he builds take the text without a length prefix; Wavelength follows the plugin's own state).
9+
- More presets by name: every program of OB-Xd's `.fxb` banks (`"<bank>.fxb#<n>"` as state), Surge 1.x patches, DecentSampler presets in the library folder its settings name, u-he NKS effect presets (`.nksfx`, Zebrify) and Reaktor Blocks racks (`.nksr`), VST2 program lists (Helm 274, Monique 143; placeholder lists such as "Program 1..n" are skipped), audjoo Helix's 1,302 `.hxp` patches (state format `helix`), Pendulate `.tide` presets (209), Surge XT Effects' 266 `.srgfx` effect presets (state format `surgefx`: Surge's plain values are normalized into the plugin's own parameter order; tables from `scripts/surge-fx-table.py`), Kilohearts snap-in presets (262 across 28 kHs effects, state format `kilohearts`), Serum 2 FX effect presets and racks (`.SerumFX`, `.SerumFXRack`, 218 by name, state format `serumfx`: the file's modules replace the plugin's rack; all 220 factory files load), MeldaProduction presets for every Melda plugin from its `<plugin>.presets` bank (about 3,200 across 42 plugins; MPowerSynth goes from 40 to 1,578: string-valued and length-prefixed entries in Melda banks are read now), Echobode's 212 `.echobode` patches (`echobode`), every program of Permut8's `.p8bank` banks (`permut8`, `"<bank>.p8bank#<0-29>"`), and TyrellN6 `.h2p` presets (older u-he builds take the text without a length prefix; Wavelength follows the plugin's own state).
1010

1111
### Fixed
1212
- Serum 2 files written by older Serum 2 builds (payload version 0, zlib + UBJSON) load; they failed with "unsupported Xfer payload version 0".

‎docs/job-format.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ A job is one JSON object. Unknown fields are ignored.
5858
| `name` | `trackN` | Used for the stem file name and in the report. |
5959
| `plugin` | required | A plugin id (`nakst.Apricot`, a VST3 class id), a name (`Apricot`, `BBC Symphony Orchestra`), a path to a `.clap`, `.vst3` or `.vst` bundle, or `path#id`. A name in several formats resolves to CLAP, then VST3, then VST2; prefix `clap:`, `vst3:` or `vst2:` to pick one (e.g. `vst3:Vital`). |
6060
| `preset` | none | A preset from the plugin's own library, by name (`"OR Cathedral Organ"`), `"Category/Name"`, or a unique part of the name. List them with `wavelength presets <plugin>`: CLAP preset discovery, a VST3 plugin's factory program list (Dexed cartridges), a VST2 plugin's program list, preset files in the plugin's preset folders (Serum 2, Odin2, u-he, Surge XT, Surge 1.x, OB-Xf, OB-Xd bank programs, `.vstpreset`), Dexed's DX7 cartridge voices, or NKS presets (DUNE 3, BBC Symphony Orchestra, ...). Applied before `state` and `params`. |
61-
| `state` | none | A preset file path, or `{"file": "...", "format": "auto"}`. Formats: `clap-preset` (CLAP, Bitwig / DAWproject container), `vstpreset` (VST3 preset), `nksf` (NKS preset: the plugin's own state), `fxp` (VST2 `.fxp`/`.fxb` program chunks such as Surge XT and OB-Xf factory patches; `"<bank>.fxb#<n>"` is program n of an OB-Xd bank), `serum` (Serum 2 `.SerumPreset`), `serumfx` (Serum 2 `.SerumFX` / `.SerumFXRack` into Serum 2 FX), `juce-valuetree` (Odin2 `.odin`), `h2p` (u-he presets), `helix` (audjoo Helix `.hxp`), `kilohearts` (kHs snap-in presets, `.ksdl` and friends), `dx7` (a DX7 cartridge voice for Dexed: `"<cart>.syx#<voice 0-31>"`), `synplant` (Synplant `.synplant` patches), `echobode` (Echobode `.echobode` patches), `permut8` (a Permut8 `.p8bank`: `"<bank>.p8bank#<program 0-29>"`, else the bank's current program), `cherry` (Cherry Audio presets), `ngrr` (Guitar Rig racks), `microtonic` (`.mtpreset` kits; `.mtdrum` drums as `"<file>.mtdrum#<channel>"`), `soundbox` (`.sbset`), `decentsampler` (`.dspreset`), `juce-string` (text presets such as Vital `.vital`), `raw`; `auto` detects them. A state or preset that changes none of the plugin's parameters gets a warning (it was ignored, or already loaded). |
61+
| `state` | none | A preset file path, or `{"file": "...", "format": "auto"}`. Formats: `clap-preset` (CLAP, Bitwig / DAWproject container), `vstpreset` (VST3 preset), `nksf` (NKS preset: the plugin's own state), `fxp` (VST2 `.fxp`/`.fxb` program chunks such as Surge XT and OB-Xf factory patches; `"<bank>.fxb#<n>"` is program n of an OB-Xd bank), `serum` (Serum 2 `.SerumPreset`), `serumfx` (Serum 2 `.SerumFX` / `.SerumFXRack` into Serum 2 FX), `surgefx` (a Surge `.srgfx` effect preset into Surge XT Effects), `juce-valuetree` (Odin2 `.odin`), `h2p` (u-he presets), `helix` (audjoo Helix `.hxp`), `kilohearts` (kHs snap-in presets, `.ksdl` and friends), `dx7` (a DX7 cartridge voice for Dexed: `"<cart>.syx#<voice 0-31>"`), `synplant` (Synplant `.synplant` patches), `echobode` (Echobode `.echobode` patches), `permut8` (a Permut8 `.p8bank`: `"<bank>.p8bank#<program 0-29>"`, else the bank's current program), `cherry` (Cherry Audio presets), `ngrr` (Guitar Rig racks), `microtonic` (`.mtpreset` kits; `.mtdrum` drums as `"<file>.mtdrum#<channel>"`), `soundbox` (`.sbset`), `decentsampler` (`.dspreset`), `juce-string` (text presets such as Vital `.vital`), `raw`; `auto` detects them. A state or preset that changes none of the plugin's parameters gets a warning (it was ignored, or already loaded). |
6262
| `params` | `{}` | `name → plain value` or the plugin's own display text (`"Cutoff": "800 Hz"`, `"StepRate": "1/16"`, parsed by the plugin), applied after the state. Keys: exact name, `Module/Name`, or `#id`. Out-of-range values are clamped (with a warning). |
6363
| `gain` | 0 | dB applied when summing into the mix. |
6464
| `transpose` | 0 | Semitones added to every note (presets that sound an octave off, key changes). |

‎scripts/surge-fx-table.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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)

‎src/preset_files.cpp‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ std::string squash(std::string s) { // "Serum 2" == "serum2", "Odin2" == "odin
2828

2929
const std::set<std::string> kExtensions = {".vstpreset", ".fxp", ".fxb", ".serumpreset", ".odin", ".h2p", ".vital", ".nksf", ".synplant",
3030
".dco106preset", ".mg1preset", ".sempreset", ".voltagepreset", ".ngrr", ".mtpreset", ".mtdrum", ".wlstate", ".sbset", ".dspreset",
31-
".hxp", ".echobode", ".serumfx", ".serumfxrack", ".tide"};
31+
".hxp", ".echobode", ".serumfx", ".serumfxrack", ".tide", ".srgfx"};
3232

3333
// a child folder of `dir` whose squashed name is one of `names`
3434
std::vector<fs::path> childrenNamed(const fs::path &dir, const std::vector<std::string> &names) {
@@ -60,6 +60,7 @@ bool belongsTo(const fs::path &file, const std::string &ext, const PluginInfo &p
6060
if (ext == ".hxp") return p == "helix";
6161
if (ext == ".echobode") return p == "echobode";
6262
if (ext == ".tide") return p == "pendulate";
63+
if (ext == ".srgfx") return p == "surgexteffects";
6364
if (ext == ".serumfx" || ext == ".serumfxrack") return p == "serum2fx";
6465
if (ext == ".fxp") { // Serum 1 patches (fxID "XfsX") turn up in Serum 2's folders; Serum 2 can't load them
6566
std::ifstream in(file, std::ios::binary);
@@ -262,6 +263,17 @@ std::vector<PresetInfo> filePresets(const PluginInfo &plugin) {
262263
dirs.push_back(kitDir() / "surge-xt" / "SurgeXTData" / "patches_3rdparty");
263264
}
264265
if (p == "surgext") dirs.push_back(fs::path(home) / "Documents/Surge XT/Patches");
266+
if (p == "surgexteffects") { // the effect presets Surge XT and Surge XT Effects share
267+
#if defined(__APPLE__)
268+
dirs.push_back("/Library/Application Support/Surge XT/fx_presets");
269+
#elif defined(_WIN32)
270+
dirs.push_back(fs::path(getenv("PROGRAMDATA") ? getenv("PROGRAMDATA") : "C:\\ProgramData") / "Surge XT/fx_presets");
271+
#else
272+
dirs.push_back(fs::exists("/usr/share/surge-xt") ? "/usr/share/surge-xt/fx_presets" : "/usr/local/share/surge-xt/fx_presets");
273+
#endif
274+
dirs.push_back(kitDir() / "surge-xt" / "SurgeXTData" / "fx_presets");
275+
dirs.push_back(fs::path(home) / "Documents/Surge XT/FX Presets");
276+
}
265277
if (p == "surge") { // Surge 1.x, the synth before Surge XT: the same .fxp patches in its own folders
266278
#if defined(__APPLE__)
267279
const fs::path surge = "/Library/Application Support/Surge";

0 commit comments

Comments
 (0)