Files
Linus Torvalds 3be9e6a482 Let an effect say how its wet and dry should be mixed
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>
2026-07-27 17:38:00 -07:00

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])