0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-14 12:44:08 +00:00
Files
torvalds-GuitarPedal/Validation/test-bench.py
Linus Torvalds 5c1d15e687 Validation: say which pedal, when there is more than one
test-bench, test-analog and measure-load all took whatever enumerated
first, and test-audio took its card from one enumeration and its MIDI
port from another - so with two boards attached it could have measured
the audio of one and talked to the other.  test-bench and test-analog at
least refused to guess and skipped; the other two did not.

All four go through pedal.discover() now and take --target (-t for
measure-load), which is passed to pedal.find() - already written to
refuse ambiguity rather than answer with the first hit.  Refusing is
still the default: no target and more than one board is a skip, not a
guess.  The Makefile passes TARGET= through, the same way check-boot
already did.

This is not hypothetical tidying.  Two boards of one revision differ
only in their serial, and the run that matters is the one where a
suspected board is compared against a known-good one - which is exactly
when picking the wrong one is both easy and invisible.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-11 20:53:23 -07:00

231 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Does the host bench agree with a real pedal?
#
# bench/ builds the pedal's own audio core for the workstation, and
# everything measured with it is quoted as though it were the pedal.
# That claim is worth exactly one thing: a run where both are asked the
# same question and the answers are put side by side. This is that run.
#
# It needs no signal generator, no cable and no second board. The
# stimulus is [TESTTONE], which is in the pedal precisely so that a test
# does not have to be told what the bench is set to, and the capture is
# the pedal's own USB audio. So the whole path is digital end to end and
# a disagreement is a real disagreement rather than an analog front end.
#
# WHAT IT ASKS
#
# [BOOST] with fold() engaged, because it is the sharpest thing in the
# tree: the transfer curve reflects about the level and reverses slope,
# which throws odd harmonics a long way up and folds a pile of them back
# over Nyquist. A waveshaper that severe is where two implementations
# of the same arithmetic would diverge if they were going to. It is
# also static - no LFO, no delay - so the output is periodic and every
# harmonic lands on a bin.
#
# The clean setting is run first as the control. It is not a null test:
# the 200 Hz basscut takes 0.18 dB out of a 440 Hz tone, and both sides
# have to agree about that too.
#
# WHAT COUNTS AS AGREEMENT
#
# Only harmonics that are actually there. A wavefolder is symmetric, so
# it makes odd harmonics and no even ones, and the even bins sit at
# -135 dBFS being numerical noise - comparing those would be comparing
# two different roundings of zero. So anything under HARMONIC_FLOOR is
# skipped, and what is left has to match closely, because these are two
# builds of one source and there is no reason for them to differ at all.
#
import subprocess
import argparse
import sys
import time
try:
import numpy as np
except ImportError:
print("test-bench: SKIPPED - no numpy")
sys.exit(0)
import audio
import bench as B
import pedal
# Effect ids, which are indexes into the firmware's effects[] - the same
# order 'bench --list' prints, since it is printing that array.
CHAIN, BOOST, TESTTONE, SETTINGS = 0, 5, 16, 18
# Pot numbers as the SysEx sees them: 0 is the mix, 1-10 are the effect's.
CHAIN_GATE, CHAIN_TRIM, CHAIN_VOLUME = 1, 4, 5
TT_LEVEL, TT_FREQ, TT_SHAPE = 1, 2, 3
BOOST_BOOST, BOOST_LEVEL, BOOST_BASSCUT, BOOST_HIGHCUT = 1, 2, 3, 4
SETTINGS_USB_OUT, USB_OUT_WET = 1, 1
N = B.WINDOW # 12000 samples: 110 whole cycles of 440 Hz
BIN = B.bin_of(B.MID_HZ, N) # ...so the fundamental is bin 110
# Below this a "harmonic" is two roundings of zero, not a signal.
HARMONIC_FLOOR = -120.0
# Two builds of one source. This is generous, not tight.
TOLERANCE_DB = 0.5
FAILED = []
def configure(p, boost, level):
"""Put the pedal in a known state, from wherever it happened to be."""
pedal.set_routing(p, TESTTONE, BOOST)
time.sleep(0.2)
for eff, pot, val in (
(CHAIN, CHAIN_GATE, 0), # gate off: it has nothing to do here
(CHAIN, CHAIN_TRIM, 60), # 0 dB
(CHAIN, CHAIN_VOLUME, 80), # 0 dB - it scales the tone, see testtone.h
(TESTTONE, 0, 120), # full mix: replace the input rather than add
(TESTTONE, TT_FREQ, 60), # pot 60 is 440 Hz exactly
(TESTTONE, TT_SHAPE, 0), # sine
(TESTTONE, TT_LEVEL, 96), # -18 dBFS
(BOOST, 0, 120),
(BOOST, BOOST_BOOST, boost),
(BOOST, BOOST_LEVEL, level),
(BOOST, BOOST_BASSCUT, 120),
(BOOST, BOOST_HIGHCUT, 120),
(SETTINGS, SETTINGS_USB_OUT, USB_OUT_WET)):
pedal.set_pot(p, eff, pot, val)
time.sleep(0.02)
#
# Long enough for the enable fade (EFF_ENABLE_STEPS, 100ms), the
# 1/512 slews on trim, volume and the two mixes, and the boost's own
# 200 Hz biquad.
#
time.sleep(1.0)
def on_bench(boost, level):
args = ["--pot", "Signal Chain:Gate=0",
"--route", "Test Tone", "--route", "Boost",
"--mix", "Test Tone=120",
"--pot", "Test Tone:Freq=60", "--pot", "Test Tone:Shape=0",
"--pot", "Test Tone:Level=96",
"--mix", "Boost=120",
"--pot", "Boost:Boost=%d" % boost, "--pot", "Boost:Level=%d" % level,
"--pot", "Boost:Basscut=120", "--pot", "Boost:Highcut=120"]
#
# Silence in: the test tone ignores its input, which is the whole
# point of it.
#
left, _, _ = B.run(args, np.zeros(N, dtype=np.float32), warmup=B.settle(1.0))
return left
def spectrum(y):
return np.abs(np.fft.rfft(y[-N:])) * 2.0 / N
def compare(name, pedal_y, bench_y, harmonics=9):
print(" %s" % name)
mp, mb = spectrum(pedal_y), spectrum(bench_y)
for n in range(1, harmonics + 1):
a, b = mp[BIN * n], mb[BIN * n]
da, db = 20 * np.log10(a + 1e-30), 20 * np.log10(b + 1e-30)
if max(da, db) < HARMONIC_FLOOR:
continue
diff = da - db
ok = abs(diff) <= TOLERANCE_DB
print(" harmonic %-2d pedal %8.2f bench %8.2f diff %+6.2f dB %s"
% (n, da, db, diff, "ok" if ok else "FAIL"))
if not ok:
FAILED.append("%s harmonic %d" % (name, n))
#
# Everything off the harmonic grid, which for a folding waveshaper is
# the harmonics that went over Nyquist and came back - the thing a
# corner actually sounds like. Compared with a looser bound because
# this one really does contain the converter's noise floor as well.
#
grid = np.zeros(len(mp), dtype=bool)
grid[::BIN] = True
grid[0] = True
ea = 20 * np.log10(np.sqrt((mp[~grid] ** 2).sum()) + 1e-30)
eb = 20 * np.log10(np.sqrt((mb[~grid] ** 2).sum()) + 1e-30)
#
# Only meaningful when there is aliasing to compare. In the clean
# case both sides are sitting on their own noise, and the pedal's is
# a real converter's - so it is reported and not judged.
#
judge = max(ea, eb) > -80.0
ok = abs(ea - eb) <= 1.0 or not judge
print(" non-harmonic pedal %8.2f bench %8.2f diff %+6.2f dB %s"
% (ea, eb, ea - eb, "ok" if ok else "FAIL" if judge else "(noise, not judged)"))
if not ok:
FAILED.append("%s aliasing" % name)
print()
def main():
#
# Which board, when there is more than one.
#
# Refusing to guess is the default and stays the default: two boards
# of one revision look identical to everything except their serial,
# and answering either of them is how a test ends up reporting on
# the board nobody was asking about. --target names one, through
# pedal.find(), which refuses ambiguity in its own right.
#
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--target", default=None,
help="serial, label or product substring naming one pedal")
args = ap.parse_args()
found = pedal.discover()
if not found:
print("%s: SKIPPED - no pedal on the USB" % "test-bench")
return 0
if args.target:
d = pedal.find(args.target, among=found)
if not d:
print("%s: SKIPPED - '%s' does not name exactly one of the %d "
"pedals here: %s"
% ("test-bench", args.target, len(found),
", ".join(x["label"] for x in found)))
return 0
elif len(found) > 1:
print("%s: SKIPPED - %d pedals and no --target; this wants exactly one"
% ("test-bench", len(found)))
return 0
else:
d = found[0]
print("test-bench: %s, card %d, port %s"
% (d["label"], d["card"], d["port"]))
print(" [TESTTONE] 440 Hz -18 dBFS into [BOOST], captured over USB")
print()
try:
subprocess.run(["arecord", "--version"], capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
print("test-bench: SKIPPED - no arecord")
return 0
for name, boost, level in (
("clean - Boost 0 dB, Level 0 dB (the 200 Hz basscut is -0.18 dB here)", 0, 120),
("folding - Boost +30 dB, Level -26.7 dB", 90, 40)):
configure(d["port"], boost, level)
compare(name, audio.capture(2, d["card"])[:, 0], on_bench(boost, level))
#
# Leave it quiet. The tone is at full mix and would otherwise still
# be playing when the next person picks the pedal up.
#
pedal.set_routing(d["port"])
pedal.set_pot(d["port"], SETTINGS, SETTINGS_USB_OUT, 2) # back to Dry
if FAILED:
print("test-bench: FAILED - %s" % ", ".join(FAILED))
return 1
print("test-bench: ok - the bench and the pedal agree")
return 0
if __name__ == "__main__":
sys.exit(main())