0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-13 20:41:53 +00:00
Files
torvalds-GuitarPedal/Validation/analyse-klon.py
Linus Torvalds 886254f0f9 Documentation: what [KLON] actually measures like
The first per-effect page, and the shape for the other sixteen.  The
README's list is an overview and stays one or two lines each; this is
where an effect gets described properly - what it models, what the
controls do, what it gets right against the real circuit and what it
does not.

Curves are mermaid xychart blocks rather than images, which is the whole
trick: a mermaid series is a list of numbers, so it renders as a chart on
GitHub *and* diffs as measurements.  A PNG would say "binary files
differ" after a DSP change, which is the same blindness that let the last
analysis rot.

Three things about drawing them that took rendered pages to learn, and
none of which are visible in the source.

Mermaid has no logarithmic axis, so the frequency plots space their
points evenly and label them by octave, which draws a log axis by
construction.  But a category is tied 1:1 to a data point, so the point
count *is* the label count - there is no thinning a crowded axis, and
eleven points is about what fits.  Blanking the labels between does not
work: equal categories collapse onto one x and the line doubles back.
Labels above 1kHz are written 1.3k, 20k and so on to keep them short.

The default palette is pale lavender on white, and two lines of it are
indistinguishable.  plotColorPalette fixes that, under themeVariables
and not under xyChart - both parse, and only one reaches the plot, which
is worth knowing because the wrong one leaves a page claiming a colour
it does not have.  The pair is Okabe-Ito blue and vermillion, which stay
apart under the common colour vision deficiencies and have contrast on
both the light and the dark GitHub themes.

And xychart has no legend, so the titles name the colours.  Inelegant,
and better than two curves with no way to tell which is which.

Two things the measurements say that the source does not, both filed:

The clipper is tanhf(), which is odd, and nothing in the path breaks the
symmetry - so the even harmonics the comment promises are not small but
structurally zero, measured 0.00% at every gain setting from 0.0 to 1.0.

And the clean half of the blend is the raw input rather than the
conditioned one, so the DC blocker, the 30Hz coupling capacitor and the
15kHz bandwidth limit exist only in the dirty path.  Measured, the gain
knob therefore swings the response at 20Hz by 12.7dB relative to
midband: flat to 20Hz at Gain 0, and 12.66dB down at Gain 1.0.  On a
Centaur both halves come off the same input buffer.

check-readme.py grows a second job with the link: a relative link in the
effect list that points at nothing is a 404 on the front page of the
repository, which is worse than no link.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-10 17:06:04 -07:00

146 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Measure [KLON], and print the numbers that go in its page.
#
# Documentation/effects/klon.md is written by hand around this output -
# the prose is the valuable half and a generator cannot write it - so
# this exists to make re-measuring a command rather than an afternoon.
# The last analysis was done once, by hand, outside the tree, and went
# quietly wrong the moment single_pole_freq() was fixed underneath it.
#
# Everything printed here is deterministic: same bench binary, same
# input, same bytes out. That is what lets check-analysis.py compare
# the page against a fresh run exactly rather than with a tolerance.
#
# Run 'make bench' first. A stale bench measures a pedal you no longer
# have, convincingly - see the issue list.
#
import sys
import math
sys.path.insert(0, ".")
import bench as B
KLON = "Klonlike"
#
# Octave centres from 20Hz. Mermaid has no logarithmic axis, so evenly
# spaced points with log-spaced labels are how a log axis gets drawn -
# which also means the point count is the label count, and eleven is
# about what fits. Trimmed to whole cycles in the analysis window, so
# these are the nominal names and B.tone() picks the exact bin.
#
def octaves(lo, hi, per_octave=1):
out, f = [], float(lo)
while f <= hi * 1.0001:
out.append(round(f / B.FS * B.WINDOW) * B.FS / B.WINDOW)
f *= 2 ** (1.0 / per_octave)
return out
#
# Axis labels, short enough to sit side by side. Mermaid ties one
# x-axis category to one data point and silently collapses duplicates -
# two points with the same label land on the same x and the line doubles
# back - so every label has to be distinct, which rules out blanking the
# ones between. Fewer points is the answer rather than fewer labels.
#
def hz_label(f):
f = round(f)
if f < 1000:
return str(f)
if f < 10000:
return f"{f / 1000:.1f}k"
return f"{round(f / 1000)}k"
def pots(gain, treble, output):
"""[KLON]'s three pots are all LINEAR(0 1), so 0..120 maps directly."""
return ["--pot", f"{KLON}:Gain={round(gain * 120)}",
"--pot", f"{KLON}:Treble={round(treble * 120)}",
"--pot", f"{KLON}:Output={round(output * 120)}"]
def routed(gain, treble=0.5, output=0.4):
return (["--pot", "Signal Chain:Gate=0", "--route", KLON]
+ pots(gain, treble, output))
def response(gain, treble, freqs, dbfs=-40.0):
"""Small signal, so the clipper is not what is being measured."""
out = []
for f in freqs:
m = B.measure(routed(gain, treble), f0=f, dbfs=dbfs)
out.append(round(float(m["gain_db"]), 2))
return out
def main():
print("# measured by analyse-klon.py - paste into Documentation/effects/klon.md\n")
#
# 1. What the gain knob does. THD and output against Gain, at a
# level a guitar actually produces.
#
print("## gain sweep, -18 dBFS in, Treble 0.5, Output 0.4\n")
print(f"{'Gain':>6} {'out dB':>9} {'THD dB':>9} {'alias dB':>9} {'even %':>8}")
gains = [i / 10.0 for i in range(11)]
for g in gains:
m = B.measure(routed(g), f0=B.MID_HZ, dbfs=-18.0)
h = B.harmonics(m["_left"], m["f0"])
even = math.sqrt(sum(v * v for v in h[1::2]))
odd = math.sqrt(sum(v * v for v in h[0::2]))
pct = 100.0 * even / (odd + 1e-30)
print(f"{g:6.1f} {m['gain_db']:9.3f} {m['thd_db']:9.1f} "
f"{m['alias_db']:9.1f} {pct:8.2f}")
#
# 2. The treble control, at both ends, small signal.
#
freqs = octaves(20, 20480, per_octave=1)
print("\n## small-signal response, Gain 0.5, Treble 0.0 and 1.0\n")
print("x-axis " + str([hz_label(f) for f in freqs]))
for t in (0.0, 1.0):
r = response(0.5, t, freqs)
print(f"Treble {t:.1f} " + str([round(v, 2) for v in r]))
#
# 3. The thing 147 is about: the clean path skips the input filters,
# so the response depends on the gain knob. Measured at both ends.
#
# Normalised, because the point is the *shape* against the gain knob
# and the two settings are 30dB apart in level. Done here rather
# than by hand into the page: a number worked out in someone's head
# is exactly the kind that goes stale unnoticed.
print("\n## small-signal response, Treble 0.5, Gain 0.0 and 1.0\n")
print("x-axis " + str([hz_label(f) for f in freqs]))
#
# Nearest measured point to midband rather than a named frequency:
# the spacing is a parameter, so naming 640Hz breaks the moment it
# changes - which it did. The reference is printed because the page
# has to label its axis with whichever one this picked.
#
mid = min(range(len(freqs)),
key=lambda i: abs(math.log2(freqs[i] / 320.0)))
print(f"reference {int(round(freqs[mid]))}Hz")
for g in (0.0, 1.0):
r = response(g, 0.5, freqs)
print(f"Gain {g:.1f} " + str(r))
print(f"Gain {g:.1f} rel " +
str([round(v - r[mid], 2) for v in r]))
#
# 4. Where it stops being clean, as the input gets louder.
#
print("\n## level sweep at Gain 0.7\n")
print(f"{'in dBFS':>9} {'out dB':>9} {'THD dB':>9} {'corner':>8}")
for m in B.level_sweep(routed(0.7), f0=B.MID_HZ):
print(f"{m['dbfs']:9.1f} {m['gain_db']:9.3f} {m['thd_db']:9.1f} "
f"{m['corner_sharpness']:8.2f}")
return 0
if __name__ == "__main__":
sys.exit(main())