#!/usr/bin/env python3 """ Linkwitz-Riley 4th-order (LR4) Sallen-Key component calculator. Cascaded unity-gain Butterworth 2nd-order stages (Q = 1/√2) yield LR4 (24 dB/octave) when two identical stages share the same fc. Formulas (as specified): Low-pass: R1 = R2 = R, C2 = 2·C1, R = 1 / (2·π·fc·√2·C1) High-pass: C1 = C2 = C, R2 = 2·R1, R1 = 1 / (2·π·fc·√2·C) Usage: python calculator.py 300 # single fc → HP + LP tables python calculator.py 300 3000 # woofer/mid/tweeter split python calculator.py 300 --series E12 python calculator.py --channel mid 300 3000 python calculator.py --list-channels 300 3000 VERIFY preferred C values against available stock before ordering. """ from __future__ import annotations import argparse import math import sys from dataclasses import dataclass from typing import Iterable, Sequence SQRT2 = math.sqrt(2.0) # ≈ 1.4142 — Butterworth Q for Sallen-Key unity-gain # Preferred seed capacitors (Farads). Calculator picks nearest E-series R. PREFERRED_C_LP = [ 10e-9, 15e-9, 22e-9, 33e-9, 47e-9, 68e-9, 100e-9, ] PREFERRED_C_HP = [ 1e-9, 2.2e-9, 3.3e-9, 4.7e-9, 6.8e-9, 10e-9, 22e-9, 47e-9, 100e-9, ] E12 = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2] E24 = [ 1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0, 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1, ] @dataclass(frozen=True) class ComponentPick: name: str ideal: float standard: float error_pct: float unit: str def nearest_e_series(value: float, series: Sequence[float]) -> float: """Snap a positive value to the nearest E12/E24 (any decade).""" if value <= 0: raise ValueError("value must be positive") exp = math.floor(math.log10(value)) mantissa = value / (10 ** exp) best_m = min(series, key=lambda m: abs(math.log(m / mantissa))) return best_m * (10 ** exp) def format_farads(c: float) -> str: if c >= 1e-6: return f"{c * 1e6:.4g} uF" if c >= 1e-9: return f"{c * 1e9:.4g} nF" return f"{c * 1e12:.4g} pF" def format_ohms(r: float) -> str: if r >= 1e6: return f"{r / 1e6:.4g} MΩ" if r >= 1e3: return f"{r / 1e3:.4g} kΩ" return f"{r:.4g} Ω" def pick(name: str, ideal: float, series: Sequence[float], kind: str) -> ComponentPick: std = nearest_e_series(ideal, series) err = 100.0 * (std - ideal) / ideal unit = "R" if kind == "R" else "C" return ComponentPick(name, ideal, std, err, unit) def design_lowpass(fc: float, c1: float, series: Sequence[float]) -> list[ComponentPick]: """Unity-gain Sallen-Key LPF: R1=R2=R, C2=2*C1, R=1/(2*pi*fc*sqrt2*C1).""" r_ideal = 1.0 / (2.0 * math.pi * fc * SQRT2 * c1) c2_ideal = 2.0 * c1 return [ pick("R1 (=R2)", r_ideal, series, "R"), pick("R2", r_ideal, series, "R"), ComponentPick("C1", c1, c1, 0.0, "C"), # chosen seed pick("C2 (=2·C1)", c2_ideal, series, "C"), ] def design_highpass(fc: float, c: float, series: Sequence[float]) -> list[ComponentPick]: """Unity-gain Sallen-Key HPF: C1=C2=C, R2=2*R1, R1=1/(2*pi*fc*sqrt2*C).""" r1_ideal = 1.0 / (2.0 * math.pi * fc * SQRT2 * c) r2_ideal = 2.0 * r1_ideal return [ ComponentPick("C1 (=C2)", c, c, 0.0, "C"), ComponentPick("C2", c, c, 0.0, "C"), pick("R1", r1_ideal, series, "R"), pick("R2 (=2·R1)", r2_ideal, series, "R"), ] def score_design(parts: Iterable[ComponentPick]) -> float: """Lower is better: sum of |error| on resistors (and snapped caps).""" return sum(abs(p.error_pct) for p in parts if p.name.startswith("R") or "2·" in p.name) def best_lp(fc: float, series: Sequence[float]) -> tuple[float, list[ComponentPick]]: candidates = [(c1, design_lowpass(fc, c1, series)) for c1 in PREFERRED_C_LP] c1, parts = min(candidates, key=lambda t: score_design(t[1])) return c1, parts def best_hp(fc: float, series: Sequence[float]) -> tuple[float, list[ComponentPick]]: candidates = [(c, design_highpass(fc, c, series)) for c in PREFERRED_C_HP] c, parts = min(candidates, key=lambda t: score_design(t[1])) return c, parts def print_table(title: str, fc: float, parts: list[ComponentPick]) -> None: print() print("=" * 72) print(f" {title} | fc = {fc:g} Hz") print("=" * 72) print(f"{'Part':<14} {'Ideal':>14} {'E-series':>14} {'Error':>10}") print("-" * 72) for p in parts: if p.unit == "R": ideal_s = format_ohms(p.ideal) std_s = format_ohms(p.standard) else: ideal_s = format_farads(p.ideal) std_s = format_farads(p.standard) print(f"{p.name:<14} {ideal_s:>14} {std_s:>14} {p.error_pct:>+9.2f}%") print("-" * 72) print(" LR4 note: cascade TWO identical stages of the above for 24 dB/oct.") print(" Op-amp: OPA2134 (or equiv. FET-input audio op-amp), unity gain.") def print_channel_bundle(f_low: float, f_high: float, series: Sequence[float]) -> None: """Print woofer LPF, midrange BP (HP+LP), tweeter HPF component sets.""" print("\n### WOOFER channel — LR4 low-pass @ {:.0f} Hz (2× identical LPF stages)".format(f_low)) _, woofer = best_lp(f_low, series) print_table("Sallen-Key LOW-PASS (one stage)", f_low, woofer) print("\n### MIDRANGE channel — LR4 HP@{:.0f} + LR4 LP@{:.0f}".format(f_low, f_high)) _, mid_hp = best_hp(f_low, series) _, mid_lp = best_lp(f_high, series) print_table("Sallen-Key HIGH-PASS (one stage)", f_low, mid_hp) print_table("Sallen-Key LOW-PASS (one stage)", f_high, mid_lp) print("\n### TWEETER channel — LR4 high-pass @ {:.0f} Hz (2× identical HPF stages)".format(f_high)) _, tweet = best_hp(f_high, series) print_table("Sallen-Key HIGH-PASS (one stage)", f_high, tweet) def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: p = argparse.ArgumentParser( description="LR4 Sallen-Key R/C calculator (E12/E24 snap)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) p.add_argument( "fc", nargs="+", type=float, help="One fc (HP+LP tables) or two: f_low f_high (full 3-way)", ) p.add_argument( "--series", choices=("E12", "E24"), default="E24", help="Resistor/capacitor E-series for snapping (default E24)", ) p.add_argument( "--channel", choices=("woofer", "mid", "tweeter", "all"), default="all", help="Which channel tables to print when two frequencies given", ) p.add_argument( "--c1-lp", type=float, default=None, help="Force LPF C1 in Farads (e.g. 22e-9) instead of auto-pick", ) p.add_argument( "--c-hp", type=float, default=None, help="Force HPF C in Farads (e.g. 10e-9) instead of auto-pick", ) return p.parse_args(argv) def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) series = E12 if args.series == "E12" else E24 if any(f <= 0 for f in args.fc): print("error: frequencies must be positive", file=sys.stderr) return 2 if len(args.fc) == 1: fc = args.fc[0] if args.c1_lp is not None: lp = design_lowpass(fc, args.c1_lp, series) else: _, lp = best_lp(fc, series) if args.c_hp is not None: hp = design_highpass(fc, args.c_hp, series) else: _, hp = best_hp(fc, series) print_table("Sallen-Key LOW-PASS (one stage)", fc, lp) print_table("Sallen-Key HIGH-PASS (one stage)", fc, hp) print("\nTip: for a 3-way system pass two frequencies, e.g.:") print(" python calculator.py 300 3000") return 0 if len(args.fc) == 2: f_low, f_high = args.fc if f_low >= f_high: print("error: need f_low < f_high", file=sys.stderr) return 2 if args.channel == "all": print_channel_bundle(f_low, f_high, series) elif args.channel == "woofer": c1 = args.c1_lp parts = design_lowpass(f_low, c1, series) if c1 else best_lp(f_low, series)[1] print_table("WOOFER LR4 — Sallen-Key LPF stage", f_low, parts) elif args.channel == "tweeter": c = args.c_hp parts = design_highpass(f_high, c, series) if c else best_hp(f_high, series)[1] print_table("TWEETER LR4 — Sallen-Key HPF stage", f_high, parts) else: # mid hp = ( design_highpass(f_low, args.c_hp, series) if args.c_hp else best_hp(f_low, series)[1] ) lp = ( design_lowpass(f_high, args.c1_lp, series) if args.c1_lp else best_lp(f_high, series)[1] ) print_table("MID LR4 — Sallen-Key HPF stage", f_low, hp) print_table("MID LR4 — Sallen-Key LPF stage", f_high, lp) return 0 print("error: pass one or two frequency arguments", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())