You've already forked torvalds-GuitarPedal
mirror of
https://github.com/torvalds/GuitarPedal.git
synced 2026-09-14 09:30:02 +00:00
Two things that were missing. Nothing here has ever sent audio *to* the pedal - audio.py is arecord and nothing else, and every aplay in the tree is aplaymidi - even though the board is a USB audio device in both directions and the firmware has had a 'USB L/R In' setting all along. The plain loop is for ears: a fixed passage going round and round is the only way to hear what a knob does, because moving it is then the only thing that changed. A guitar cannot do that; you cannot play the same bar twice. One aplay fed repeatedly rather than one per pass, or there is a click at every wrap. --verify is for the thing compare-cabs.py cannot answer. That script runs the pedal's own DSP on this machine - the same source through a different compiler for a different instruction set - so it is a claim about the hardware rather than a measurement of it. This plays a passage in, captures what comes out, aligns the two and reports the null, the latency and the drift. It measures the analog floor first with nothing playing, because Pre-FX *adds* to the input jack rather than replacing it, so whatever the jack picks up is the floor the null cannot beat. It refuses to write a pot to a board whose build stamp is not this tree's. Effect indices come out of build/effect_map.h and adding an effect renumbers everything after it, and a pot write to the wrong effect sets a real pot on a real effect and says nothing at all. The same literal is in the elf and in the identity reply, so comparing them is free. decode() and find_onset() move to audio.py, which is where the second caller made them belong. Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
268 lines
11 KiB
Python
Executable File
268 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
#
|
|
# Listening to [CAB], for ears rather than for the FFT.
|
|
#
|
|
# It writes one wav per setting and a page that switches between them
|
|
# without losing the playback position, which is the only way a small
|
|
# tonal difference is audible as a difference rather than as a restart.
|
|
#
|
|
# Level matching is the whole reason this is a script and not a handful
|
|
# of commands. The rows do not have the same output level, they do not
|
|
# have the same level *as a function of input* either, and louder wins
|
|
# every uncontrolled A/B ever run. Everything below is matched to the
|
|
# same RMS over the same passage before it is written out, and the gain
|
|
# that took is printed so it is visible rather than hidden.
|
|
#
|
|
# It began as the head-to-head that chose between the biquad cab sim and
|
|
# this one, and the level matching is what it learned there: the first
|
|
# comparison it ran was decided entirely by one of them being 3 dB up.
|
|
#
|
|
# The source defaults to Inputs/BassForLinus.mp3, which is a DI
|
|
# recording - no cab on it already - so it is a fair thing to put a cab
|
|
# sim in front of. It is also a bass, which is not what most of this is
|
|
# voiced for; pass --source for something better.
|
|
#
|
|
import argparse
|
|
import base64
|
|
import os
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import audio
|
|
import bench as B
|
|
import pots as P
|
|
|
|
FS = 48000
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
GATE = ["--pot", "Signal Chain:Gate=0"]
|
|
|
|
|
|
ROWS = ["Small-Combo", "American-1x12", "British-4x12",
|
|
"Modern-4x12", "Bass-15"]
|
|
|
|
|
|
def cab(row, **over):
|
|
"""One cabinet row at its defaults, except for what is named."""
|
|
a = ["--route", "Cabinet", "--mix", "Cabinet=120",
|
|
"--pot", f"Cabinet:Cabinet={ROWS.index(row)}"]
|
|
for k, val in over.items():
|
|
a += P.arg("Cabinet", k, val)
|
|
return GATE + a
|
|
|
|
|
|
def rms(x):
|
|
return float(np.sqrt(np.mean(np.asarray(x, dtype=np.float64) ** 2)))
|
|
|
|
|
|
def write_wav(path, x):
|
|
"""RIFF by hand, because `import wave` does not get the standard
|
|
library here - Validation/wave.py is the waveform viewer and it
|
|
shadows the stdlib module for anything with this directory on its
|
|
path, which is everything in here."""
|
|
q = np.clip(np.asarray(x) * 32767.0, -32768, 32767).astype("<i2")
|
|
d = q.tobytes()
|
|
le = lambda v, n: int(v).to_bytes(n, "little")
|
|
hdr = (b"RIFF" + le(36 + len(d), 4) + b"WAVEfmt " + le(16, 4)
|
|
+ le(1, 2) + le(1, 2) + le(FS, 4) + le(FS * 2, 4)
|
|
+ le(2, 2) + le(16, 2) + b"data" + le(len(d), 4))
|
|
with open(path, "wb") as f:
|
|
f.write(hdr + d)
|
|
return len(hdr) + len(d)
|
|
|
|
|
|
PAGE = """<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<title>__TITLE__</title><style>
|
|
body{background:#14161a;color:#e6e6e6;font:15px/1.5 system-ui,sans-serif;
|
|
margin:0;padding:2rem;max-width:46rem}
|
|
h1{font-size:1.2rem;margin:0 0 .3rem}p{color:#9aa4b2;margin:.2rem 0 1.4rem}
|
|
button{background:#222831;color:#e6e6e6;border:1px solid #39414d;border-radius:6px;
|
|
padding:.7rem 1rem;font:inherit;cursor:pointer;margin:0 .4rem .4rem 0;min-width:9rem}
|
|
button.on{background:#0072b2;border-color:#0072b2;color:#fff}
|
|
#bar{height:5px;background:#222831;border-radius:3px;margin:1.2rem 0 .4rem;overflow:hidden}
|
|
#pos{height:100%;width:0;background:#0072b2}
|
|
small{color:#6f7986}kbd{background:#222831;border:1px solid #39414d;border-radius:4px;padding:0 .35rem}
|
|
</style></head><body>
|
|
<h1>__TITLE__</h1>
|
|
<p>Switching keeps the playback position, so the change is audible as a change
|
|
rather than as a restart. Number keys pick a take; <kbd>space</kbd> plays and pauses.</p>
|
|
<div id="btns"></div>
|
|
<div id="bar"><div id="pos"></div></div>
|
|
<small id="now"></small>
|
|
<script>
|
|
const TAKES = __TAKES__;
|
|
const els = TAKES.map(t => { const a = new Audio(t.src); a.preload='auto'; a.loop=true; return a; });
|
|
let cur = 0, playing = false;
|
|
const btns = document.getElementById('btns');
|
|
TAKES.forEach((t,i) => {
|
|
const b = document.createElement('button');
|
|
b.textContent = (i+1) + '. ' + t.name;
|
|
b.onclick = () => pick(i);
|
|
btns.appendChild(b);
|
|
});
|
|
function paint(){
|
|
[...btns.children].forEach((b,i) => b.className = i===cur ? 'on' : '');
|
|
document.getElementById('now').textContent = TAKES[cur].note || '';
|
|
}
|
|
function pick(i){
|
|
if (i === cur) return;
|
|
const t = els[cur].currentTime;
|
|
els[cur].pause();
|
|
cur = i;
|
|
els[cur].currentTime = t;
|
|
if (playing) els[cur].play();
|
|
paint();
|
|
}
|
|
function toggle(){
|
|
playing = !playing;
|
|
if (playing) els[cur].play(); else els[cur].pause();
|
|
}
|
|
document.addEventListener('keydown', e => {
|
|
if (e.code === 'Space'){ e.preventDefault(); toggle(); return; }
|
|
const n = parseInt(e.key, 10);
|
|
if (n >= 1 && n <= TAKES.length) pick(n-1);
|
|
});
|
|
setInterval(() => {
|
|
const a = els[cur];
|
|
if (a.duration) document.getElementById('pos').style.width =
|
|
(100*a.currentTime/a.duration) + '%';
|
|
}, 80);
|
|
paint();
|
|
</script></body></html>
|
|
"""
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--seconds", type=float, default=15.0)
|
|
ap.add_argument("--offset", default="auto",
|
|
help="where in the recording to start, in seconds; "
|
|
"'auto' skips the quiet lead-in")
|
|
ap.add_argument("--peak", type=float, default=0.35,
|
|
help="input peak, in the pedal's own scale")
|
|
ap.add_argument("--out", default="cab-shootout",
|
|
help="prefix for the wavs and the page")
|
|
ap.add_argument("--blind", action="store_true",
|
|
help="shuffle the labels and print the key at the end")
|
|
ap.add_argument("--source", default=os.path.join(HERE, "Inputs",
|
|
"BassForLinus.mp3"))
|
|
ap.add_argument("--pre", metavar="EFFECT", default=None,
|
|
help="route a drive in front of the cab - a cab sim's "
|
|
"stated job is taming the fizz off a distorted amp, "
|
|
"and a clean signal never asks it to")
|
|
ap.add_argument("--pre-pot", action="append", default=[],
|
|
metavar="POT=VAL", help="a raw 0..120 pot on --pre")
|
|
ap.add_argument("--trim", type=float, default=None,
|
|
help="[CHAIN] Trim in dB, for a guitar that is not hot")
|
|
ap.add_argument("--row", default=None, choices=ROWS,
|
|
help="with --ladder, which cabinet to walk Drive on")
|
|
ap.add_argument("--drive", type=float, default=15.0,
|
|
help="Drive for the row comparison, in dB")
|
|
ap.add_argument("--ladder", action="store_true",
|
|
help="one row at five Drive settings, which is where "
|
|
"it stops being a filter and starts breaking up")
|
|
args = ap.parse_args()
|
|
|
|
#
|
|
# The drive goes in front of every take including the dry one, so
|
|
# that what is being compared is still only the cab.
|
|
#
|
|
if args.trim is not None:
|
|
GATE.extend(P.arg("Signal Chain", "Trim", args.trim))
|
|
|
|
pre = []
|
|
if args.pre:
|
|
pre = ["--route", args.pre, "--mix", f"{args.pre}=120"]
|
|
for kv in args.pre_pot:
|
|
pre += ["--pot", f"{args.pre}:{kv}"]
|
|
|
|
off = None if args.offset == "auto" else float(args.offset)
|
|
dry, off = audio.decode(args.source, args.seconds, off)
|
|
dry = dry / max(np.abs(dry).max(), 1e-9) * args.peak
|
|
print(f"source: {os.path.basename(args.source)} "
|
|
f"{off:.1f}..{off + len(dry) / FS:.1f}s"
|
|
f"{' (found)' if args.offset == 'auto' else ''}, "
|
|
f"peak {args.peak}, {len(dry)} samples")
|
|
|
|
#
|
|
# What is actually in the source, per octave, relative to its
|
|
# loudest octave. A cab sim's differences above 5kHz do not matter
|
|
# on material that has nothing there, and which of those is true is
|
|
# a measurement rather than an opinion.
|
|
#
|
|
spec = np.abs(np.fft.rfft(dry * np.hanning(len(dry)))) ** 2
|
|
freq = np.fft.rfftfreq(len(dry), 1.0 / FS)
|
|
edges = [20, 40, 80, 160, 320, 640, 1280, 2560, 5120, 10240, 20480]
|
|
band = []
|
|
for lo, hi in zip(edges[:-1], edges[1:]):
|
|
m = (freq >= lo) & (freq < hi)
|
|
band.append(10 * np.log10(spec[m].sum() + 1e-30))
|
|
top = max(band)
|
|
print("source content, dB re its loudest octave:")
|
|
print(" " + " ".join(f"{lo:>7}" for lo in edges[:-1]))
|
|
print(" " + " ".join(f"{v - top:7.1f}" for v in band))
|
|
|
|
if args.ladder:
|
|
row = args.row or "Modern-4x12"
|
|
takes = [("nocab", "no cab at all", GATE + pre)]
|
|
for db in (0.0, 10.0, 18.0, 24.0, 30.0):
|
|
takes.append((f"drive{int(db)}", f"{row}, Drive {db:+.0f} dB",
|
|
GATE + pre + cab(row, Drive=db)[len(GATE):]))
|
|
else:
|
|
#
|
|
# Every row at the same Drive. They are all at the same
|
|
# small-signal level by construction - norm() divides the
|
|
# sensitivity out - so what is left is the voicing and how early
|
|
# each one lets go, which is the whole of what a row is.
|
|
#
|
|
takes = [("nocab", "no cab at all", GATE + pre)]
|
|
for row in ROWS:
|
|
takes.append((row.lower(), f"{row}, Drive {args.drive:+.0f} dB",
|
|
GATE + pre + cab(row, Drive=args.drive)[len(GATE):]))
|
|
|
|
ref = rms(dry)
|
|
rows = []
|
|
for key, name, argv in takes:
|
|
y, _, info = B.run(argv, dry.astype(np.float32), warmup=B.settle())
|
|
y = np.asarray(y, dtype=np.float64)[-len(dry):]
|
|
raw = rms(y)
|
|
g = ref / max(raw, 1e-12)
|
|
y = y * g
|
|
pk = float(np.abs(y).max())
|
|
if pk > 0.99: # matched loudness can still clip a wav
|
|
y = y * (0.99 / pk)
|
|
rows.append((key, name, y, 20 * np.log10(g), pk, info))
|
|
|
|
order = list(range(len(rows)))
|
|
if args.blind:
|
|
random.shuffle(order)
|
|
|
|
print()
|
|
print(f"{'take':14} {'match gain':>11} {'peak after':>11} {'clipped':>8}")
|
|
manifest = []
|
|
for slot, idx in enumerate(order):
|
|
key, name, y, gdb, pk, info = rows[idx]
|
|
path = f"{args.out}-{'take%d' % (slot + 1) if args.blind else key}.wav"
|
|
write_wav(path, y)
|
|
label = f"Take {slot + 1}" if args.blind else name
|
|
manifest.append({"name": label, "src": os.path.basename(path),
|
|
"note": "" if args.blind else name})
|
|
print(f"{key:14} {gdb:+10.2f} dB {pk:11.3f} {info.get('clipped', 0):8.0f}")
|
|
|
|
page = PAGE.replace("__TITLE__", "Cabinet - one source, several speakers") \
|
|
.replace("__TAKES__", repr(manifest).replace("'", '"'))
|
|
html = f"{args.out}.html"
|
|
open(html, "w").write(page)
|
|
print(f"\npage: {os.path.abspath(html)}")
|
|
if args.blind:
|
|
print("\nkey (look after listening):")
|
|
for slot, idx in enumerate(order):
|
|
print(f" Take {slot + 1} = {rows[idx][1]}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|