0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-13 20:41:53 +00:00
Files
torvalds-GuitarPedal/Validation/measure-load.py
Linus Torvalds f478e2de89 Validation: pin the USB output tap while measuring the load
process_output() returns immediately when "USB L/R Out" is None, and
otherwise stores a frame into a ring with a release barrier - every
sample, on the audio core, whether or not a host is listening.  Measured
across the four modes inside one boot, empty chain, nothing else moving:

  None             9.263 %
  Wet              9.605 %      +0.343 %
  Dry (default)    9.569 %      +0.306 %
  Wet/Dry          9.611 %      +0.348 %

Small, and still big enough to be mistaken for something else.  Two runs
an hour apart differed by 0.37% of the sample period and got as far as a
filed issue guessing at browsers and USB polling; what had actually
changed was that this setting had been moved in the web app in between.
The earlier session reads as Dry and the later one as None, which is the
whole of it.

A difference between two effects cancels the tap.  An absolute load does
not, and comparing absolutes across firmwares is what this tool is for -
so a run pins it to None and lets the closing program change put the
scene back, exactly as it already does with the routing.  What gets
printed is therefore what the chain costs with the USB tap deliberately
excluded, and the header says so.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-11 15:36:04 -07:00

255 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
#
# What an effect costs the audio core, measured on the pedal itself.
#
# HOW THE PEDAL KNOWS
#
# single_sample() times the spin waiting for the RX DMA to produce a
# sample, one sample in sixteen. Per sample period the core either
# spins there or works, and the period is fixed by the DMA, so the spin
# is the idle fraction exactly and 1 - idle is the load. Nothing is
# estimated and nothing is a model; it is the audio loop timing itself.
#
# Two things make that worth reading finely. The firmware times the
# spin in cpu cycles, so a tick is 0.03% of the sample period rather
# than the 4.8% a 1MHz timer gives; and telemetry carries the result at
# fourteen bits - an MSB where the old seven-bit byte was, an LSB after
# it - so 0.006% a step rather than 0.787%. Neither is much use without
# the other.
#
# Measured across four boots, which is what that buys:
#
# empty chain 9.577 .. 9.614 %
# reverb routed 31.624 .. 31.691 % std 0.014 %
#
# So (routed - empty) from a single reading is meaningful, and -b N is
# for confidence rather than for averaging anything away. The empty
# reading is printed beside it because a baseline that has moved is the
# first sign that something outside this has changed.
#
# Numbers taken before either change are not comparable at this
# precision: with a microsecond timer the idle reading sat in one of two
# states 1.2% apart and held one for a whole boot, which is the
# instrument and not the pedal.
#
# THE BASELINE IS A SETTING, NOT JUST A BUILD
#
# process_output() returns immediately when "USB L/R Out" is None and
# otherwise stores a frame into a ring with a release barrier every
# sample, on the audio core, whether or not a host is listening.
# Measured, that is +0.306% of the sample period for the default Dry,
# and +0.348% for Wet/Dry.
#
# Which is small and was still large enough to be mistaken for
# something else: two runs an hour apart differed by 0.37% and got as
# far as an issue blaming the measurement, when what had changed was
# that this setting had been moved in the web app in between. A
# difference of two effects cancels it, an absolute load does not.
#
# So a run pins it to None and lets the closing program change put the
# scene back. The number this prints is therefore what the *chain*
# costs, with the USB tap excluded on purpose.
#
# WHY AN IDLE PEDAL IS A VALID PLACE TO MEASURE
#
# Nothing need be plugged in. The reverb runs eight combs and four
# allpasses every sample whatever the input is, so its cost has no data
# dependency worth the name. An effect with a branch on the signal -
# the gate, a compressor above its threshold - would not be safe to
# measure this way and is not what this is for.
#
# WHY IT IS SAFE TO RUN
#
# Routing is set live over SysEx and nothing is ever saved: the flash is
# untouched, and the run ends with a program change that reloads the
# scene. Unplugging would do the same.
#
# Called as: ./measure-load.py [-b N] [effect-id ...]
# -b N re-measure across N reboots (default 1)
# ids default 11, the Reverb
#
import re
import statistics
import subprocess
import sys
import time
sys.path.insert(0, ".")
import pedal
SETTLE = 1.0 # effect fades are 100 ms, the load meter 21 ms
DEFAULT = [11] # Reverb
#
# The settings pseudo-effect is last in effects[], and its first pot is
# "USB L/R Out". Pinned to None for the duration of a run - see the note
# on the baseline below - and put back by the program change at the end,
# the same way the routing is.
#
SETTINGS = 18
USB_OUT_POT = 1
USB_OUT_NONE = 0
STEP_PCT = 100.0 / 16383 # what one telemetry step is worth, 14-bit
COARSE = 128 # ...and how many of them the old 7-bit step was
def effect_names(map_h="../build/effect_map.h"):
try:
text = open(map_h).read()
except OSError:
return {}
return {i: n for i, n in enumerate(re.findall(r'\.name = "([^"]*)"', text))}
def _frames(text):
"""Every telemetry body in a blob of hex, as a load value."""
out = []
for m in re.finditer(r"F0 7D 0B((?: [0-9A-F]{2})+?) F7", text.upper()):
body = [int(v, 16) for v in m.group(1).split()]
#
# 14 bits where the firmware sends them, and the old 7-bit byte
# scaled up where it does not, so an older build stays
# comparable with a newer one.
#
if len(body) >= 7:
out.append((body[5] << 7) | body[6])
elif len(body) >= 6:
out.append(body[5] * COARSE)
return out
def _via_amidi(dev, n, dump_s):
"""One invocation per reading: sends and dumps in about half a second."""
out = []
for _ in range(n):
r = subprocess.run(["amidi", "-p", dev, "-S", "F0 7D 0B F7",
"-d", "-t", str(dump_s)],
capture_output=True, text=True)
if r.returncode:
return None
out += _frames(" ".join(r.stdout.split()))
return out
def _via_seq(p, n, gap=0.25):
"""The sequencer, for when something else holds the raw device.
Slower - holding aseqdump open is itself what makes the raw device
busy, so pedal.send() drops to aplaymidi at two seconds a call - and
the extra traffic is not nothing when the thing being measured is
the audio core's spare time. Correct, though, and available when
the fast path is not.
"""
dump = subprocess.Popen(["aseqdump", "-p", p], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True)
try:
time.sleep(0.5)
for _ in range(n):
pedal.send(p, 0x0B)
time.sleep(gap)
time.sleep(1.0)
finally:
dump.terminate()
text = dump.stdout.read()
dump.wait()
hexed = " ".join(re.findall(r"System exclusive\s+((?:[0-9A-Fa-f]{2} ?)+)",
text))
return _frames(" ".join(hexed.split()))
_slow_warned = []
def sample_loads(p, n, dump_s=0.4):
"""n telemetry frames, by whichever transport is available.
amidi is preferred and costs about half a second a reading. Whether
it can be had depends on what else has the MIDI port open, and the
ordinary case is the WebMIDI app in a browser tab - which also polls
telemetry five times a second while it is there. So being refused
is a condition rather than an error, exactly as pedal.py's _play()
already assumes, and the fallback is not a rare path.
"""
dev = pedal.rawmidi(p)
if dev:
got = _via_amidi(dev, n, dump_s)
if got is not None:
return got
if not _slow_warned:
print(" (raw MIDI device busy - using the sequencer, four times "
"slower and noisier)")
_slow_warned.append(True)
return _via_seq(p, n)
def fmt(vals):
"""A reading as a percentage range, however many bits it arrived in."""
if not vals:
return "-"
lo, hi = min(vals) * STEP_PCT, max(vals) * STEP_PCT
return "%.3f" % lo if lo == hi else "%.3f..%.3f" % (lo, hi)
def reboot(p):
pedal.enter_bootsel(p)
time.sleep(3)
subprocess.run(["picotool", "reboot"], capture_output=True)
time.sleep(7)
def one_pass(p, ids, n=6):
pedal.set_pot(p, SETTINGS, USB_OUT_POT, USB_OUT_NONE)
pedal.set_routing(p)
time.sleep(SETTLE)
empty = sample_loads(p, n)
pedal.set_routing(p, *ids)
time.sleep(SETTLE)
routed = sample_loads(p, n)
pedal.set_routing(p)
return empty, routed
def main():
args = sys.argv[1:]
boots = 1
if args and args[0] == "-b":
boots = int(args[1])
args = args[2:]
ids = [int(a) for a in args] or DEFAULT
names = effect_names()
label = " + ".join(names.get(i, "effect %d" % i) for i in ids)
p = pedal.port()
print("pedal on %s, %s, %d boot(s)" % (p, label, boots))
print("USB L/R Out pinned to None; the scene goes back at the end\n")
print(" boot empty (noisy) routed routed load")
routed_all = []
for k in range(boots):
if k:
reboot(p)
p = pedal.port()
empty, routed = one_pass(p, ids)
if not routed:
print(" %4d no reply" % (k + 1))
continue
routed_all += routed
print(" %4d %-16s %-16s %6.3f %%"
% (k + 1, fmt(empty), fmt(routed),
statistics.mean(routed) * STEP_PCT))
if routed_all:
m = statistics.mean(routed_all)
print("\n %s routed: %.3f %% of the sample period" % (label, m * STEP_PCT))
if len(set(routed_all)) > 1:
print(" spread %.3f %% across every reading, %.3f %% std"
% ((max(routed_all) - min(routed_all)) * STEP_PCT,
statistics.pstdev(routed_all) * STEP_PCT))
pedal.program_change(p, 0)
print(" scene 0 reloaded")
main()