Skip to content

Instantly share code, notes, and snippets.

@Windows81
Last active July 6, 2022 05:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Windows81/f2cbc5394947b8538409069603ccb96c to your computer and use it in GitHub Desktop.
Save Windows81/f2cbc5394947b8538409069603ccb96c to your computer and use it in GitHub Desktop.
Place in C:\Users\USERNAME\Documents\Image-Line\FL Studio\Presets\Plugin presets\Generators\Patcher to allow custom intonation in FL Studio.

To see the most up-to-date version of this program, go here.


I've created a Patcher preset that allows music producers to use their own intonation schemes without having to pitch-shift each individual note. The default setting is tuned to C-Major Just with a Styrus set likewise to the default preset.

Setup

Download and place CustomIntonation.fst in the directory shown below to allow custom intonation in FL Studio (for Windows users). Replace Sytrus with your own stock plugins; VSTs do not work correctly under this setup. Fruity plugins that use a dedicated audio-sample tab are not supported by Patcher. Also make sure the final VFX envelope and 'To FL Studio' are both linked. Pitch-correction knobs can each go up to 100 cents in both direction, whereas the global pitch-shift knob goes ±1200 cents.

%userprofile%\Documents\Image-Line\FL Studio\Presets\Plugin presets\Generators\Patcher

Add your own presets!

If you have Python 3.X installed, use the supplied add_scale.py script to generate a compatible pitch profile from the contents of a .scl file. Use the --fst flag to add that new profile to the preset list; for that option to work properly, the _template.fst file must also be in the same directory.

from file_util import DIRECTORY, FST_STORE
import argparse
import os
import math
import sys
import struct
from scale_util import parse_interval
KEYS = ["C ", "C#", "D ", "D#", "E ", "F ", "F#", "G ", "G#", "A ", "A#", "B "]
FST_SEEK = [
2407,
325,
4489,
6573,
8655,
10737,
12821,
14903,
16987,
19069,
21153,
23235,
25337,
]
SUB_CHARS = {
"[": "{",
"]": "}",
"/": "-",
"\\": "-",
";": "-",
",": "-",
">": "-",
"<": "-",
"&": "-",
"*": "-",
":": "-",
"%": "-",
"=": "-",
"+": "-",
"@": "-",
"!": "-",
"#": "-",
"^": "-",
"(": "-",
")": "-",
"|": "-",
"?": "-",
"^": "-",
}
class info:
description: str
offset: float
pitches: list[tuple[float, int, str]]
fst_path: str
def calc(lines: list[str], shift: float) -> tuple[list[tuple[float, int, str]], float]:
a: list[(float, str)] = [(shift % 12, "1/1")]
n: int = -1
c: int = 1
for l in lines:
l = l.strip()
if l.startswith("!") or l == "":
continue
elif n == -1:
n -= 1
elif n == -2:
n = int(l)
if n > 12:
raise ValueError(f"Should contain at most twelve notes, but has {n}.")
elif c < 12:
v = parse_interval(l)
if v % 12 != 0:
a.append(((v + shift) % 12, l))
c += 1
a.sort()
if c < 12:
t = c
a += [None] * (12 - c)
while t > 0:
t -= 1
v = a[t]
a[t] = None
if v[0] < 12:
i2 = int(v[0])
if a[i2 - 1] is None:
a[i2 - 1] = v
if a[i2 + 0] is None:
a[i2 + 0] = v
if i2 < 11 and a[i2 + 1] is not None and a[i2 + 0][0] == a[i2 + 1][0]:
if i2 > 1 and a[i2 - 1][0] == a[i2 + 0][0]:
a[i2 - 1] = v
a[i2 + 0] = v
if i2 < 11 and a[i2 + 1] is None:
a[i2 + 1] = v
if i2 < 10 and a[i2 + 2] is not None and a[i2 + 1][0] == a[i2 + 2][0]:
a[i2 + 1] = v
if a[0] is None and not a[11] is None:
a[0] = (a[11][0] - 12, *a[11][1:])
if any(map(lambda n: n is None, a)):
d = [(t[0] - i, i, *t[1:]) if t else (0, i, None) for i, t in enumerate(a)]
raise ValueError("Some intervals were not able to be filled.", d)
d = [(s - i, i, *e) for i, (s, *e) in enumerate(a)]
mn, mx, off = min(d)[0], max(d)[0], 0
if mx - mn > 2:
raise ValueError("Intervals are too far apart to use in Patcher.", d)
elif mn < -1 or mx > 1:
off = (mx + mn) / 2
d = [(s - off, *e) for s, *e in d]
return d, off
def interpret(o) -> info:
desc: str = None
a: list[str] = []
for l in o.file or sys.stdin:
if not desc:
desc = l.strip()
fn = (
os.path.split(o.file.name)[1]
if o.file and "<" not in o.file.name
else "".join(SUB_CHARS.get(c, c) for c in desc)
)
a.append(l)
t = info()
off = o.pitch_shift
t.pitches, t.offset = calc(a, off)
t.description = desc
t.fst_path = (
o.fst_file.replace("{}", fn)
if o.fst_file
else f"{FST_STORE}/{fn}.fst"
if o.fst
else None
)
return t
def output_pitches(t: info):
for (s, i, l) in t.pitches:
print(f'{KEYS[i]} {s*100:+06.1f} cents - "{l}"')
def output_table(t: info):
print(t.description)
output_pitches(t)
print(f" {t.offset*100:+06.1f} cents")
fst_from_file(t)
def fst_from_file(t: info) -> bool:
if not t.fst_path:
return False
rf = open(f"{DIRECTORY}/_template.fst", "rb")
b = list(rf.raw.readall())
FST_OFFSET = FST_SEEK[12]
for (s, *_), p in zip(t.pitches, FST_SEEK):
b[p : p + 4] = struct.pack("f", 0.5 + s / 2)
b[FST_OFFSET : FST_OFFSET + 4] = struct.pack("f", 0.5 + t.offset / 24)
wf = open(t.fst_path, "wb")
wf.write(bytes(b))
rf.close()
wf.close()
return True
def parse_args():
a = argparse.ArgumentParser()
a.add_argument("file", type=argparse.FileType("r"))
a.add_argument("-o", "--pitch-shift", type=float, default=0)
a.add_argument("--fst", "-b", action="store_true")
a.add_argument("--fst-file", type=str)
return a.parse_args()
if __name__ == "__main__":
try:
output_table(interpret(parse_args()))
except ValueError as x:
print(x.args[0])
if len(x.args) > 1:
print("\nERROR LOG:")
output_pitches(x.args[1])
exit(1)
$t = @{
523.251 = "~523.251 Hz [C♮]"
400.000 = "400 Hz [G+]"
432.000 = "432 Hz [A−]"
440.000 = "440 Hz [A♮"
480.000 = "480 Hz [B−]"
500.000 = "500 Hz [B+]"
512.000 = "512 Hz [C−]"
576.000 = "576 Hz [D−]"
600.000 = "600 Hz [D+]"
640.000 = "640 Hz [E−]"
672.000 = "672 Hz [E+]"
720.000 = "720 Hz [F+]"
768.000 = "768 Hz [G−]"
}
foreach ($i in $t.GetEnumerator()) {
$k = $i.Value;
$o = [math]::log2($i.Key / 440) * 12 - 3;
$f = "~/Documents/Image-Line/FL Studio/Presets/Plugin presets/Effects/Control Surface/Scala/$k";
mkdir $f -ErrorAction Ignore;
ls $PSScriptRoot/scl/* | % {
py $PSScriptRoot/add_scale.py -o $o --fst-file "$f/{}.fst" $_.FullName
}
}
from add_scale import fst_from_file, calc, info
from file_util import get_fst_paths
import os.path
import os
def export():
for f in get_fst_paths():
if os.path.exists(f.fst_path):
continue
try:
i = info()
i.description = ""
i.fst_path = f.fst_path
i.pitches, i.offset = calc(open(f"./scl/{f.scale}"), f.shift)
if fst_from_file(i):
print(f'Successfully parsed at {f.dir_name}: "{f.scale}"')
except ValueError:
pass
if __name__ == "__main__":
export()
import math
import os
FST_STORE = f'{os.environ["USERPROFILE"]}/Documents/Image-Line/FL Studio/Presets/Plugin presets/Effects/Control Surface'
DIRECTORY = os.path.dirname(os.path.realpath(__file__))
FREQUENCIES = {
freq: (math.log2(freq / 440) * 12 - 3, dir_name)
for freq, dir_name in {
523.251: "~523.251 Hz [C♮]",
400.000: "400 Hz [G+]",
432.000: "432 Hz [A−]",
440.000: "440 Hz [A♮]",
480.000: "480 Hz [B−]",
500.000: "500 Hz [B+]",
512.000: "512 Hz [C−]",
576.000: "576 Hz [D−]",
600.000: "600 Hz [D+]",
640.000: "640 Hz [E−]",
672.000: "672 Hz [E+]",
720.000: "720 Hz [F+]",
768.000: "768 Hz [G−]",
}.items()
}
def list_scales() -> list[str]:
return os.listdir(f"{DIRECTORY}/scl")
def setup_fst_dirs():
for (_, dir_name) in FREQUENCIES.values():
fst_dir = f"{FST_STORE}/Scala/{dir_name}"
if not os.path.exists(fst_dir):
os.mkdir(fst_dir)
class fst_info:
fst_path: str
freq: float
shift: str
dir_name: float
scale: str
def get_fst_paths() -> list[fst_info]:
a = []
for freq, (shift, dir_name) in FREQUENCIES.items():
fst_dir = f"{FST_STORE}/Scala/{dir_name}"
for scale in list_scales():
i = fst_info()
i.fst_path = f"{fst_dir}/{scale}.fst"
i.freq = freq
i.shift = shift
i.dir_name = dir_name
i.scale = scale
a.append(i)
return a
class scl_info:
scl_path: str
scale: str
def get_scl_paths() -> list[scl_info]:
a = []
for scale in list_scales():
i = scl_info()
i.scl_path = f"{DIRECTORY}/scl/{scale}"
i.scale = scale
a.append(i)
return a
import math
def parse_interval(l: str) -> float:
l = l.strip()
if "/" in l:
f = l.split("/")
return 12 * math.log2(float(f[0]) / float(f[1]))
elif "\\" in l:
f = l.split("\\")
return float(f[0]) / float(f[1]) * 12
return float(l) / 100
from file_util import get_scl_paths
from scale_util import parse_interval
import argparse
import math
def search(lines: list[str], smtn_diff: float, epsilon: float = 1e-9):
matches: list[tuple[str, str]] = []
a: list[(float, str)] = [(0, "1/1")]
smtn_diff = smtn_diff % 12
n: int = -1
c: int = 1
for l in lines:
l = l.strip()
if l.startswith("!") or l == "":
continue
elif n == -1:
n -= 1
elif n == -2:
n = int(l)
if n > 12:
raise ValueError(f"Should contain at most twelve notes, but has {n}.")
elif c < 12:
v = parse_interval(l)
if v % 12 != 0:
a.append((v, l))
c += 1
for i in range(0, len(a)):
for j in range(i + 1, len(a)):
diff = abs((a[i][0] - a[j][0]) % 12)
if math.isclose(diff, smtn_diff, abs_tol=epsilon):
matches.append((a[j][1], a[i][1]))
elif math.isclose(12 - diff, smtn_diff, abs_tol=epsilon):
matches.append((a[i][1], a[j][1]))
return matches
if __name__ == "__main__":
a = argparse.ArgumentParser()
a.add_argument("semitones", type=parse_interval)
a.add_argument("epsilon", type=float, nargs="?", default=1e-4)
args = a.parse_args()
d = {}
for i in get_scl_paths():
with open(i.scl_path) as f:
try:
r = search(f.readlines(), args.semitones, args.epsilon)
l = len(r)
if l > 0:
d[i.scale] = r
print(f'- "{i.scale}":')
print(f"{l:2d} matching interval(s) found:")
for (l1, l2) in r:
print(f"{l1:>13s} » {l2:<13s}")
print()
except ValueError:
pass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment