You've already forked ivan-torvalds-GuitarPedal
forked from AllSpiceMirrors/torvalds-GuitarPedal
Back when mixing was each effect's own business, they all did a plain linear blend except for the vibrato, which did an equal-power one with a sin/cos pair. When the mix moved into the effect chain that distinction got flattened, and the vibrato quietly lost it. It shouldn't have. The two laws differ in what they assume about whether the wet and dry are correlated, and each is wrong by 3dB in the other's case. When the wet is a filtered version of the dry - which is most of what's here - the amplitudes add, half of each gives you back the original, and an equal-power mix puts a 3dB hump right in the middle of the sweep where everyone leaves the knob. When they're uncorrelated the powers add instead, and it's the linear mix that leaves a 3dB hole there. So it isn't one law for everything, it is a property of the effect, and the effect header is where it should say so. Add a 'MIX:' annotation alongside PRIORITY and DEFAULT_MIX, defaulting to LINEAR, and mark the four whose wet really is decorrelated from the dry: vibrato, echo, reverb and the pitch shifter. Phaser and flanger stay linear on purpose, even though it's tempting. An allpass or comb has the same magnitude spectrum as the input and only differs in phase, and the cancellation is the whole point - equal power would fill the notches back in by 3dB and make them sound weak. Nothing uses the field yet; do_effect_step() still does what it did. Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
301 lines
12 KiB
Python
Executable File
301 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import sys
|
|
import os
|
|
import re
|
|
import json
|
|
import math
|
|
|
|
def default_pot_value(pot):
|
|
"""Turn a pot default in engineering units into the raw 0..120 value.
|
|
|
|
This is deliberately the *only* place that conversion happens. The
|
|
same number goes into the C table and into the schema the web app
|
|
reads, so the two can't drift apart - which they did, back when the
|
|
app recomputed it for itself."""
|
|
y = pot['default']
|
|
curve = pot['curve']
|
|
|
|
if curve in ('RAW', 'ENUM'):
|
|
return int(round(y))
|
|
|
|
a, b = 0.0, 1.0
|
|
if len(pot['args']) >= 2:
|
|
a = float(pot['args'][0])
|
|
b = float(pot['args'][1])
|
|
|
|
if b == a:
|
|
p = 0.0
|
|
else:
|
|
# A default outside the declared range is a mistake in the effect
|
|
# header, but don't take a fractional root of a negative number
|
|
# over it - python hands back a complex and the build dies
|
|
# somewhere confusing.
|
|
ratio = max(0.0, (y - a) / (b - a))
|
|
if curve == 'LINEAR':
|
|
p = ratio
|
|
elif curve == 'FREQUENCY':
|
|
p = ratio ** (1/3.0)
|
|
elif curve == 'SQUARED':
|
|
p = ratio ** 0.5
|
|
elif curve == 'EXPONENTIAL':
|
|
p = math.log2(y / a) / math.log2(b / a) if (a != 0 and y != 0) else 0.0
|
|
else:
|
|
p = 0.0
|
|
|
|
return max(0, min(120, int(round(p * 120))))
|
|
|
|
|
|
def generate(audio_dir, out_h, out_js, out_md):
|
|
ui_effects = [] # List of dicts for JS/Schema output
|
|
effects_data = []
|
|
|
|
for filename in os.listdir(audio_dir):
|
|
if not filename.endswith('.h'):
|
|
continue
|
|
base = filename[:-2]
|
|
header_path = os.path.join(audio_dir, filename)
|
|
|
|
with open(header_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
name_match = re.search(r'//\s*NAME:\s*(.*?)\s*\[(.*?)\]', content)
|
|
priority_match = re.search(r'//\s*PRIORITY:\s*(\d+)', content)
|
|
if not name_match:
|
|
continue
|
|
|
|
# effect_id will be assigned later based on sorted index
|
|
effect_id = 0
|
|
|
|
full_name = name_match.group(1).strip()
|
|
short_name = name_match.group(2).strip()
|
|
priority = int(priority_match.group(1)) if priority_match else 100
|
|
|
|
def_mix_match = re.search(r'//\s*DEFAULT_MIX:\s*(\S+)', content)
|
|
def_mix = float(def_mix_match.group(1)) if def_mix_match else 1.0
|
|
|
|
# LINEAR unless the effect says otherwise - see 'enum mix_law'
|
|
mix_match = re.search(r'//\s*MIX:\s*(LINEAR|POWER)', content)
|
|
mix_law = mix_match.group(1) if mix_match else 'LINEAR'
|
|
|
|
pots = []
|
|
# Match: // POT: "Name" CURVE(a b c) = 1.0 Unit
|
|
pot_lines = re.findall(r'//[ \t]*POT:[ \t]*"([^"]+)"[ \t]+(LINEAR|FREQUENCY|SQUARED|EXPONENTIAL|RAW|ENUM)(?:\(([^)]+)\))?(?:[ \t]*=[ \t]*(\S+))?(?:[ \t]+(\S+))?[ \t]*\n?', content)
|
|
|
|
for p_label, p_curve, p_args, p_def, p_unit in pot_lines:
|
|
enum_list = None
|
|
if p_curve == 'ENUM' and p_args:
|
|
enum_list = p_args.split()
|
|
|
|
default_val = 0.0
|
|
if p_def:
|
|
if enum_list and p_def in enum_list:
|
|
default_val = float(enum_list.index(p_def))
|
|
else:
|
|
try:
|
|
default_val = float(p_def)
|
|
except ValueError:
|
|
default_val = 0.0
|
|
|
|
pots.append({
|
|
'label': p_label,
|
|
'unit': p_unit if p_unit else "none",
|
|
'curve': p_curve,
|
|
'args': p_args.split() if p_args else [],
|
|
'enum': enum_list,
|
|
'default': default_val
|
|
})
|
|
|
|
effects_data.append({
|
|
'id': effect_id,
|
|
'base': base,
|
|
'full_name': full_name,
|
|
'short_name': short_name,
|
|
'priority': priority,
|
|
'def_mix': def_mix,
|
|
'mix_law': mix_law,
|
|
'pots': pots,
|
|
'header_path': header_path
|
|
})
|
|
|
|
# Sort by priority, then by filename to break ties.
|
|
#
|
|
# Priorities are a hint about where an effect wants to sit in the
|
|
# chain, not a total order, so several effects sharing one is fine
|
|
# and expected. What is not fine is the tie-break coming from
|
|
# os.listdir(), which is inode order: that made the ids depend on
|
|
# the filesystem, and the ids are what end up in saved scenes and on
|
|
# the wire. Filenames are unique in a directory, so this key is a
|
|
# total order and the result is the same everywhere.
|
|
effects_data.sort(key=lambda x: (x['priority'], x['base']))
|
|
|
|
# Auto-assign index as ID
|
|
for i, e in enumerate(effects_data):
|
|
e['id'] = i
|
|
|
|
# Build maps
|
|
for e_idx, e_data in enumerate(effects_data):
|
|
ui_pots = []
|
|
for pot in e_data['pots']:
|
|
min_v = 0.0
|
|
max_v = 1.0
|
|
if pot['curve'] != 'ENUM' and len(pot['args']) >= 2:
|
|
min_v = float(pot['args'][0])
|
|
max_v = float(pot['args'][1])
|
|
elif pot['curve'] == 'ENUM' and pot['enum']:
|
|
max_v = float(len(pot['enum']) - 1)
|
|
|
|
pot['pot_val'] = default_pot_value(pot)
|
|
|
|
ui_pots.append({
|
|
"name": pot['label'],
|
|
"unit": pot['unit'],
|
|
"curve": pot['curve'],
|
|
"min": min_v,
|
|
"max": max_v,
|
|
"default": pot['default'],
|
|
"defaultPot": pot['pot_val'],
|
|
"enum": pot['enum']
|
|
})
|
|
|
|
ui_effects.append({
|
|
"id": e_data['id'],
|
|
"base": e_data['base'],
|
|
"name": e_data['full_name'],
|
|
"shortName": e_data['short_name'],
|
|
"defMix": e_data['def_mix'],
|
|
"mixLaw": e_data['mix_law'],
|
|
"pots": ui_pots
|
|
})
|
|
|
|
# Generate effect_map.h
|
|
with open(out_h, 'w') as f:
|
|
f.write("// Auto-generated by gen_effects.py\n")
|
|
|
|
for e_data in effects_data:
|
|
base = e_data['base']
|
|
struct_name = f"{base}_effect" if base != "eq" else "EQ"
|
|
e_data['struct_name'] = struct_name
|
|
|
|
f.write(f"static struct effect {struct_name};\n")
|
|
|
|
for p_idx, pot in enumerate(e_data['pots']):
|
|
fn_name = f"{base}_pot{p_idx}"
|
|
pot['fn_name'] = fn_name
|
|
|
|
if pot['curve'] == 'ENUM' and pot['enum']:
|
|
enum_name = f"{base}_pot{p_idx}_enum"
|
|
pot['enum_name'] = enum_name
|
|
f.write(f"static const char *const {enum_name}[] = {{ ")
|
|
for val in pot['enum']:
|
|
f.write(f'"{val}", ')
|
|
f.write("NULL };\n")
|
|
|
|
args_str = ", ".join(pot['args'])
|
|
if pot['curve'] == 'RAW' or pot['curve'] == 'ENUM':
|
|
f.write(f"static float {fn_name}(unsigned char pot) {{ return pot; }}\n")
|
|
elif pot['curve'] == 'LINEAR':
|
|
f.write(f"static float {fn_name}(unsigned char pot) {{ return linear_pot(pot, {args_str}); }}\n")
|
|
elif pot['curve'] == 'FREQUENCY':
|
|
f.write(f"static float {fn_name}(unsigned char pot) {{ return frequency_pot(pot, {args_str}); }}\n")
|
|
elif pot['curve'] == 'SQUARED':
|
|
f.write(f"static float {fn_name}(unsigned char pot) {{ float p = POT_TO_FLOAT(pot); return linear(p*p, {args_str}); }}\n")
|
|
elif pot['curve'] == 'EXPONENTIAL':
|
|
a_val = float(pot['args'][0])
|
|
b_val = float(pot['args'][1])
|
|
log2_ratio = math.log2(b_val / a_val) if a_val != 0 else 0
|
|
f.write(f"static float {fn_name}(unsigned char pot) {{ float p = POT_TO_FLOAT(pot); return {a_val}f * pow2(p * {log2_ratio}f); }}\n")
|
|
|
|
# Declare the two entry points ahead of the header, marked as
|
|
# running on the audio core. Gcc carries a section attribute
|
|
# from the declaration to the definition, so the effect headers
|
|
# don't have to know about any of this.
|
|
f.write(f"static void __audio_func({base}_init)(unsigned char[10]);\n")
|
|
f.write(f"static float __audio_func({base}_step)(float);\n")
|
|
f.write(f"#include \"../effects/{base}.h\"\n")
|
|
|
|
f.write(f"static struct effect {struct_name} = {{\n")
|
|
f.write(f"\t.name = \"{e_data['full_name']}\",\n")
|
|
f.write(f"\t.short_name = \"{e_data['short_name']}\",\n")
|
|
f.write(f"\t.def_mix = {e_data['def_mix']}f,\n")
|
|
f.write(f"\t.mix_law = MIX_{e_data['mix_law']},\n")
|
|
f.write(f"\t.init = {base}_init,\n")
|
|
f.write(f"\t.step = {base}_step,\n")
|
|
f.write(f"\t.pots = {{\n")
|
|
|
|
for p_idx, pot in enumerate(e_data['pots']):
|
|
pot_val = pot['pot_val']
|
|
|
|
unit_str = f"\"{pot['unit']}\"" if pot['unit'] and pot['unit'] != "none" else "NULL"
|
|
enum_str = f", {pot['enum_name']}" if 'enum_name' in pot else ""
|
|
f.write(f"\t\tEFFECT_POT(\"{pot['label']}\", {unit_str}, {pot['fn_name']}, {pot_val}{enum_str}),\n")
|
|
|
|
f.write("\t}\n")
|
|
f.write("};\n\n")
|
|
|
|
f.write("static struct effect *const effects[] = {\n")
|
|
for e_data in effects_data:
|
|
f.write(f"\t&{e_data['struct_name']},\n")
|
|
f.write("};\n\n")
|
|
|
|
f.write(f"#define EFFECT_COUNT {len(effects_data)}\n\n")
|
|
|
|
# Generate midi_schema.h next to effect_map.h
|
|
schema_path = os.path.join(os.path.dirname(out_h), "midi_schema.h")
|
|
with open(schema_path, 'w') as f:
|
|
f.write("// Auto-generated by gen_effects.py\n")
|
|
json_str = json.dumps(ui_effects, separators=(',', ':'))
|
|
# Escape quotes for C string literal
|
|
json_str = json_str.replace('"', '\\"')
|
|
f.write(f'static const char *const midi_schema_json = "{json_str}";\n')
|
|
|
|
with open(out_js, 'w') as f:
|
|
f.write("// Auto-generated by gen_effects.py\n")
|
|
# We don't output PEDAL_EFFECTS here anymore, the webapp will fetch it dynamically!
|
|
f.write("const GLOBAL_ENABLE_CC = 20;\n")
|
|
|
|
with open(out_md, 'w') as f:
|
|
f.write("# MIDI Implementation\n\n")
|
|
f.write("## Universal CCs (Global Controls)\n\n")
|
|
f.write("- **CC 7:** Main Volume\n")
|
|
f.write("- **CC 11:** Expression\n")
|
|
f.write("- **CC 20:** Global Bypass\n")
|
|
f.write("- **CC 64:** Tap Tempo\n")
|
|
f.write("- **CC 94:** Noise Gate Threshold\n")
|
|
f.write("- **CC 95:** Master Mix\n\n")
|
|
|
|
f.write("## Program Change (Scenes)\n\n")
|
|
f.write("- **PC 0-31:** Load Scene 0-31 from EEPROM\n\n")
|
|
|
|
f.write("## SysEx Deep Editing\n\n")
|
|
f.write("The pedal uses SysEx messages for deep editing and dynamic feature discovery. Header: `F0 7D`.\n\n")
|
|
f.write("### Effects Reference\n\n")
|
|
for e_idx, e_data in enumerate(effects_data):
|
|
f.write(f"#### {e_data['full_name']} (ID: {e_data['id']})\n\n")
|
|
for p_idx, pot in enumerate(e_data['pots']):
|
|
if pot['curve'] == 'ENUM' and pot['enum']:
|
|
range_str = ", ".join([f"{i}={v}" for i, v in enumerate(pot['enum'])])
|
|
input_str = f"Index: {p_idx} (0-{len(pot['enum'])-1}, maps to: {range_str})"
|
|
elif pot['curve'] == 'RAW':
|
|
input_str = f"Index: {p_idx} (0-127, Raw value)"
|
|
else:
|
|
if len(pot['args']) >= 2:
|
|
min_val = pot['args'][0]
|
|
max_val = pot['args'][1]
|
|
unit = pot['unit']
|
|
if unit and unit != "none":
|
|
range_str = f"{min_val} to {max_val} {unit}"
|
|
else:
|
|
range_str = f"{min_val} to {max_val}"
|
|
else:
|
|
range_str = "0.0 to 1.0"
|
|
input_str = f"Index: {p_idx} (0-120, maps to: {range_str})"
|
|
f.write(f"- **{pot['label']}:** {input_str}\n")
|
|
f.write("\n")
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 5:
|
|
print("Usage: gen_effects.py <audio_dir> <out_h> <out_js> <out_md>")
|
|
sys.exit(1)
|
|
|
|
generate(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4])
|