0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-14 04:43:53 +00:00
Files
torvalds-GuitarPedal/Validation/test-analog.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

242 lines
9.0 KiB
Python
Executable File

#!/usr/bin/env python3
#
# How much of the signal survives the trip out through the DAC, down a
# cable, and back in through the ADC.
#
# test-bench.py checks that the pedal's arithmetic matches the host's,
# and it does that entirely in the digital domain - test tone in,
# USB capture out, no analog anywhere. That is the right way to check
# the DSP and it says nothing at all about the thing the pedal is
# actually plugged into. This is the other half.
#
# WHAT IT NEEDS
#
# A patch cable from the pedal's output back to its own input. One
# board, one cable, nothing else: no generator, no second pedal, no
# second clock. That last part is what makes the measurement easy -
# the DAC and the ADC are the same codec on the same clock, so there is
# no drift to chase and no resampling to argue about.
#
# HOW IT READS
#
# 'Wet/Dry' on the USB output puts the processed signal on the left
# channel and the *raw ADC sample* on the right, both from the same
# instant. So the left is what we sent to the DAC and the right is what
# came back, and the only things between them are a converter, a cable
# and another converter.
#
# The two are not in the same units - see the note at the top of
# audio.py - so the right one is scaled by SAMPLE_TO_FLOAT first. That
# constant mirrors the firmware's, and mirroring it is exactly what
# makes this measurement able to lie: scale the raw channel by the same
# factor process_input() applies and the two cancel, so a pedal with
# 1.7dB of gain built into its conversion reads perfectly flat. That
# happened here. The number below is the pedal's analog gain only
# because the two constants agree; test-audio.py's "input/output
# scaling" check is what enforces that they do.
#
# The analog path is then a gain and a delay, and both are removed
# before anything is called a residual. The delay is solved from the
# phase of the fundamental rather than searched for: at 440 Hz one
# sample is 3.3 degrees, so a search on a 0.01-sample grid leaves 0.03
# degrees of phase error behind, which is -65 dB of residual - larger
# than the distortion being measured. That mistake is easy to make and
# looks like a result.
#
import argparse
import sys
import time
try:
import numpy as np
except ImportError:
print("test-analog: SKIPPED - no numpy")
sys.exit(0)
import audio
import pedal
CHAIN, TESTTONE, SETTINGS = 0, 16, 18
CHAIN_GATE, CHAIN_TRIM, CHAIN_VOLUME = 1, 4, 5
TT_LEVEL, TT_FREQ, TT_SHAPE = 1, 2, 3
SETTINGS_USB_OUT, USB_OUT_WET_DRY = 1, 3
TONE_HZ = 440.0
SHAPE_SINE, SHAPE_NOISE = 0, 3
def configure(p, level, shape):
pedal.set_routing(p, TESTTONE)
time.sleep(0.2)
for eff, pot, val in ((CHAIN, CHAIN_GATE, 0),
(CHAIN, CHAIN_TRIM, 60),
(CHAIN, CHAIN_VOLUME, 80),
(TESTTONE, 0, 120),
(TESTTONE, TT_FREQ, 60), # 440 Hz exactly
(TESTTONE, TT_SHAPE, shape),
(TESTTONE, TT_LEVEL, level),
(SETTINGS, SETTINGS_USB_OUT, USB_OUT_WET_DRY)):
pedal.set_pot(p, eff, pot, val)
time.sleep(0.02)
time.sleep(1.0)
def round_trip(card):
"""(sent, returned), both in the pedal's internal float units."""
y = audio.capture(3, card)
mid = y[len(y) // 4: len(y) // 4 * 3]
return mid[:, 0], mid[:, 1] * audio.SAMPLE_TO_FLOAT
def loopback_present(p, card):
configure(p, 96, SHAPE_SINE)
sent, back = round_trip(card)
n = len(back)
b0 = int(round(TONE_HZ * n / audio.RATE))
mag = np.abs(np.fft.rfft(back)) * 2 / n
peak = int(np.argmax(mag))
return abs(peak - b0) <= 2 and 20 * np.log10(mag[peak] + 1e-30) > -60
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-analog")
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-analog", 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-analog", len(found)))
return 0
else:
d = found[0]
p, card = d["port"], d["card"]
print("test-analog: %s, card %d, port %s" % (d["label"], card, p))
if not loopback_present(p, card):
print("test-analog: SKIPPED - the ADC cannot hear the tone, so there is")
print(" no patch cable from the output back to the input")
return 0
print(" output patched back to input; [TESTTONE] 440 Hz out the")
print(" DAC, back in the ADC, both read off one USB capture")
print()
#
# Linearity first. A converter that is doing its job is a constant
# gain, and the interesting question is over how much of the range.
#
print(" %9s %11s %10s %9s %10s" %
("sent", "returned", "gain", "THD", "noise"))
gains = []
for level, dbfs in ((36, -63.0), (60, -45.0), (84, -27.0),
(96, -18.0), (108, -9.0), (114, -4.5)):
configure(p, level, SHAPE_SINE)
sent, back = round_trip(card)
n = len(back)
b0 = int(round(TONE_HZ * n / audio.RATE))
ms, mb = (np.abs(np.fft.rfft(x)) * 2 / n for x in (sent, back))
grid = np.zeros(len(mb), dtype=bool)
grid[::b0] = True
grid[0] = True
harm = np.sqrt(sum(mb[b0 * i] ** 2 for i in range(2, 13)))
gain = 20 * np.log10(mb[b0] / ms[b0])
gains.append(gain)
print(" %8.1f%s %10.2f%s %8.2f%s %8.1f%s %9.1f%s" %
(dbfs, " dBFS", 20 * np.log10(mb[b0]), " dBFS", gain, " dB",
20 * np.log10(harm / mb[b0]), " dB",
20 * np.log10(np.sqrt((mb[~grid] ** 2).sum())), " dBFS"))
spread = max(gains) - min(gains)
print()
print(" gain is constant to %.3f dB across 58 dB of level" % spread)
#
# ...and then the null, which is the whole question in one number.
#
configure(p, 96, SHAPE_SINE)
sent, back = round_trip(card)
n = len(sent)
b0 = int(round(TONE_HZ * n / audio.RATE))
S, Bk = np.fft.rfft(sent), np.fft.rfft(back)
f = np.fft.rfftfreq(n)
#
# Solved, not searched - see the header.
#
H = Bk[b0] / S[b0]
lag = -np.angle(H) / (2 * np.pi * b0 / n)
aligned = np.fft.irfft(S * np.exp(-2j * np.pi * f * lag), n) * abs(H)
resid = back - aligned
sig = np.sqrt((back ** 2).mean())
res = np.sqrt((resid ** 2).mean())
m = np.abs(np.fft.rfft(resid)) * 2 / n
fund = np.abs(Bk[b0]) * 2 / n
grid = np.zeros(len(m), dtype=bool)
grid[::b0] = True
grid[0] = True
print()
print(" nulled against what was sent, at -18 dBFS:")
print(" delay %8.4f samples = %.4f ms" % (lag, lag / 48.0))
print(" gain %+8.4f dB" % (20 * np.log10(abs(H))))
print(" residual %8.1f dB below the signal = %.4f%%"
% (20 * np.log10(res / sig), 100 * res / sig))
print(" of which")
print(" harmonics 2..12 %8.1f dB converter distortion"
% (20 * np.log10(np.sqrt(sum(m[b0 * i] ** 2 for i in range(2, 13))) / fund)))
print(" everything else %8.1f dB noise, at %.1f dBFS absolute"
% (20 * np.log10(np.sqrt((m[~grid] ** 2).sum()) / fund),
20 * np.log10(np.sqrt((m[~grid] ** 2).sum()))))
#
# The latency again, independently. delay_samples() wants a
# broadband reference and the test tone has a Noise shape for
# exactly this - a sine correlates with itself once a cycle and the
# peak says which cycle the arithmetic liked.
#
configure(p, 108, SHAPE_NOISE)
sent, back = round_trip(card)
print()
print(" the same delay from noise and correlation: %.2f samples = %.3f ms"
% (audio.delay_samples(sent, back, 2000),
audio.delay_samples(sent, back, 2000) / 48.0))
#
# Leave it quiet, and the USB output back where it was found.
#
pedal.set_routing(p)
pedal.set_pot(p, SETTINGS, SETTINGS_USB_OUT, 2)
print()
print("test-analog: reported, not judged - see the header of test-loop.py")
print(" on why a bench measurement is a hypothesis until the")
print(" cable has been moved and the number followed it")
return 0
if __name__ == "__main__":
sys.exit(main())