0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-19 05:33:46 +00:00
Files
Linus Torvalds 0c1b9c3db3 Split Software/ into the four things it actually was
'Software' was the directory everything that was not KiCad ended up in,
which stopped describing anything a while ago - Validation and the web
app are software too.  Worse, it put the shared parts inside the
firmware, where they read as the firmware's own.

They are not.  Effects/ has three consumers built from it: the firmware,
Validation's bench, and the web app's controls, all generated from the
same POT: comments by gen_effects.py.  Audio/ has two - the bench
compiles the same biquads, the same envelope followers and the same
single_sample(), which is the whole reason a measurement on a
workstation says anything about the pedal.  Neither belongs under
Firmware/, so neither is under it any more:

  Effects/    one file per effect
  Audio/      the DSP they are built from, and the audio loop
  Firmware/   the rest of what runs on the pedal, and the submodules
  WebMIDI/    the web app
  scripts/    what the build runs
  Validation/ unchanged
  Hardware/, Documentation/, Images/

CMakeLists.txt and the wrapper Makefile move to the top with them,
because the build now consumes four of those directories and generates
into a fifth.  board.local and build/ come along; MIDI_CC_MAP.md is
generated into Documentation/ rather than into the old Software/ root.

scripts/ goes with the build rather than staying under the firmware,
because six of the ten had nothing to do with the firmware: gen_effects.py
reads Effects/ and writes to three different places, pow2/log2/quarter_sine
generate Audio/'s tables, check-readme.py compares Effects/ against the
README, and server.py serves the web app.  Four of them are invoked from
Validation, which was reaching into Firmware/ for tooling - the same
burying this commit is undoing.  The four that really are about the
firmware are ELF checks the top-level build drives anyway, and a second
scripts directory would only be a second place to look.

C includes say "Audio/foo.h" and the generated map says
"Effects/bar.h", with the repository root on the include path for both
the firmware and the bench.  Spelling the directory out rather than
relying on a bare name is what keeps Audio/cycles.h shimmable: a quoted
include searches the including file's own directory first.

The submodules are renamed as well as moved.  git mv updates their paths
but leaves the section names, and 'Software/pico-sdk' surviving in
.gitmodules would be the word this commit removes, still load-bearing.
That meant the nested modules under pico-sdk too - six .git files
pointing into .git/modules/Software - which is why 'git submodule update
--init --recursive' is worth running once after pulling this.

Verified rather than assumed: a clean configure and build, make check
(failing only on the missing-eeprom case it already failed on),
check-effects, all four analysis pages reproducing every series and
drawing every chart, and a flash to the board that still measures a
routed reverb where it did before.

One latent bug fell out of it.  bench/coeff declared only quarter_sine.h
of the three generated math tables, and Audio/util.h includes pow2.h and
log2.h as well - so building that target with an empty gen/ could never
have worked.  'make bench' builds bench/bench first, which generates all
three, so it stayed hidden until this rebuilt everything from nothing.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-11 13:48:26 -07:00

370 lines
15 KiB
C

// NAME: Tape Echo [ECHO]
// PRIORITY: 80
// MIX: POWER // the tail is old signal, uncorrelated with what's playing now
// POT: "Blend" LINEAR(0.0 1.0) = 0.2
// POT: "Sustain" LINEAR(0.0 1.1) = 0.3
// POT: "Time" EXPONENTIAL(50.0 672.0) = 375.0 ms
// POT: "Record" LINEAR(0.1 2.0) = 0.5
// POT: "Tone" EXPONENTIAL(1739.25 17392.53) = 6400.0 Hz
// POT: "WowFlut" LINEAR(0.0 1.0) = 0.2
// POT: "Mode" ENUM(Normal SOS) = Normal
//
// "EchoKing" for Cleveland Music Co. Hothouse DIY DSP Platform
// Simplified and converted to C for the RP2350 direct pedal format
//
// A digital model of the Maestro Echoplex tape delay, using a mashup of the
// EP-1, EP-2, and EP-3 variants to create a single "Echoplex-inspired" model with
// a modern overall sound. The EP-1 and EP-2 are the warmest and grittiest, and the
// EP-3 is a much more aggressive, brighter sound with less wow and flutter. So,
// we borrow from the EP-3's edgier character and reduced wow and flutter, but keep
// the tube preamp character of the earlier models. Roll your own below.
//
// Three things define the sound: the preamp (always in the signal path, colouring
// the dry tone), the tape medium (bandwidth limits, soft saturation, and repeats
// that darken each pass), and the transport mechanics (wow and flutter). All three
// are modelled here.
//
// This is not a licensed product of or affiliated with Gibson Brands, Maestro, or
// any other trademark holder. It is an educational DSP example.
//
//
// One-pole low-pass: y[n] = (1-a)*x[n] + a*y[n-1], 6 dB/oct rolloff.
//
// The Echoplex tone is shaped by *gentle* 1st-order rolloffs at the record,
// playback, and feedback stages. Use a one-pole here to stay true to the OG.
// More context at the bottom of the file, but the short version is that a
// biquad with a low Q cuts too much above the corner, and stacked biquads
// cut too much, too fast.
//
// Pole coeff: a = exp(-2*pi*fc/fs) = pow2(-2*pi*fc/fs / ln(2)).
// The 9.06472 constant is 2*pi/ln(2) precomputed.
//
struct echo_onepole {
float a, z;
};
static inline void echo_onepole_set_cutoff(struct echo_onepole *lpf, float fc_hz)
{
lpf->a = pow2(-9.06472028f * fc_hz / SAMPLES_PER_SEC);
}
static inline float echo_onepole_step(struct echo_onepole *lpf, float x)
{
// y[n] = (1-a)*x[n] + a*y[n-1], rearranged to
// y[n] = x + a*(y[n-1] - x) to fold into a single fma.
lpf->z = x + lpf->a * (lpf->z - x);
return lpf->z;
}
// Delay range. Fixed (not per-model and not exposed via a toggle), so a pair
// of constants is enough; shared by echo_init and echo_time_display.
#define ECHO_DELAY_MIN_S 0.050f
#define ECHO_DELAY_MAX_S 0.672f
// log2(0.672 / 0.050) ~= 3.75, used by the log-taper mapping.
#define ECHO_DELAY_LOG2_RATIO 3.75f
#define ECHO_WOW_MOD_FREQ 0.15f
struct echo_model {
float preamp_drive;
float preamp_asymmetry;
float record_fc;
float playback_fc;
float feedback_fc;
float wow_scale;
float wow_rate;
float flutter_scale;
float flutter_rate;
float max_feedback;
float delay_smooth;
};
//
// Echoplex model and EP-1, 2, & 3 params reference
//
// These values are the result of lots of A/B testing against original units
// and tweaking to approximate the character of each. Use them to create your
// own Echoplex sound by mixing and matching parameters.
//
// EP-1: Tube (12AX7 single-ended), early cartridge transport. Loosest
// mechanics, strongest even-harmonic colouration, most wow and flutter.
//
// EP-2: Tube (12AX7 + 12AU7), revised circuit. The canonical Echoplex sound.
// Thick, warm, the dry tone is perceptibly coloured even without any echo.
//
// EP-3: Solid state (JFET/FET). Tighter and more focused. The preamp is famous
// as a standalone boost. Better motor regulation = much less wow and flutter.
//
// struct member EP-1 EP-2 EP-3
// ----------------------------------------------------
// preamp_drive 1.4f 1.5f 1.2f
// preamp_asymmetry 0.22f 0.18f 0.04f
// record_fc 5500.0f 6000.0f 7500.0f
// playback_fc 4500.0f 5000.0f 6000.0f
// feedback_fc 3500.0f 4000.0f 5000.0f
// wow_scale 1.40f 1.00f 0.65f
// wow_rate 1.2f 1.3f 1.5f
// flutter_scale 1.20f 1.00f 0.60f
// flutter_rate 8.0f 9.0f 10.5f
// max_feedback 0.95f 1.00f 1.05f
// delay_smooth 0.00006f 0.00008f 0.00012f
//
static const struct echo_model model =
{
/*preamp_drive=*/1.27f,
/*preamp_asymmetry=*/0.15f,
/*record_fc=*/6500.0f,
/*playback_fc=*/5500.0f,
/*feedback_fc=*/4500.0f,
/*wow_scale=*/0.85f,
/*wow_rate=*/1.5f,
/*flutter_scale=*/0.90f,
/*flutter_rate=*/9.0f,
/*max_feedback=*/1.05f,
/*delay_smooth=*/0.00012f
};
struct {
float target_blend, blend;
float target_sustain, sustain;
float target_delay_s, delay_s;
float target_record_level, record_level;
float target_tone, tone, last_tone;
float target_wow, wow;
int sos_mode;
u32 wow_phase;
u32 wow_mod_phase;
u32 flutter_phase;
float preamp_dc_offset; // tanhf(preamp_asymmetry): DC bias the waveshaper introduces
float last_blend, dry_gain, wet_gain;
struct echo_onepole record_lpf;
struct echo_onepole playback_lpf;
struct echo_onepole feedback_lpf;
unsigned idx;
int16_t array[32768];
} echo;
static inline void echo_init(unsigned char pot[10])
{
echo.target_blend = echo_blend_pot(pot);
echo.target_sustain = echo_sustain_pot(pot);
// The EXPONENTIAL metadata sets up the curve directly, so the pot value
// is already in milliseconds. We just convert it to seconds.
float time_ms = echo_time_pot(pot);
echo.target_delay_s = time_ms / 1000.0f;
echo.target_record_level = echo_record_pot(pot);
echo.target_tone = echo_tone_pot(pot);
echo.target_wow = echo_wowflut_pot(pot);
// Mode: 0=Normal, 1=SOS
echo.sos_mode = (pot[ECHO_MODE] == 1);
echo.preamp_dc_offset = tanhf(model.preamp_asymmetry);
echo.last_blend = -1.0f;
// Initialize LPFs (one-pole, 6 dB/oct; see comments at bottom of file).
echo_onepole_set_cutoff(&echo.record_lpf, model.record_fc);
echo_onepole_set_cutoff(&echo.feedback_lpf, model.feedback_fc);
}
static inline float echo_step(float in)
{
float coeff = 0.0008f;
echo.blend = linear(coeff, echo.blend, echo.target_blend);
echo.sustain = linear(coeff, echo.sustain, echo.target_sustain);
echo.record_level = linear(coeff, echo.record_level, echo.target_record_level);
echo.tone = linear(coeff, echo.tone, echo.target_tone);
echo.wow = linear(coeff, echo.wow, echo.target_wow);
// Slow glide on delay-time changes; avoids zipper noise and gives the
// characteristic tape-head pitch shift when the time pot moves.
echo.delay_s = linear(model.delay_smooth, echo.delay_s, echo.target_delay_s);
// 1. Preamp waveshaper
// The Echoplex signal always passes through the preamp, so the colouration
// is present even with BLEND fully dry.
float preamp_out;
if (model.preamp_asymmetry > 0.05f) {
// Tube: asymmetric waveshaper generates even-order harmonics (biased tanhf).
float arg = in * model.preamp_drive + model.preamp_asymmetry;
preamp_out = tanhf(arg) - echo.preamp_dc_offset;
} else {
// Solid state: symmetric odd-harmonic clipping (FET/transistor character).
preamp_out = tanhf(in * model.preamp_drive);
}
// 2. Wow and Flutter
// Wow is the slow pitch wobble from motor speed variation. A secondary
// ~0.15 Hz oscillator drifts the wow rate slightly over time, creating
// "wandering" motor irregularity rather than a steady periodic cycle.
// Wow and flutter depths are proportional to the current delay time: the
// longer the loop, the more absolute pitch variation you get.
echo.wow_mod_phase += fraction_to_u32(ECHO_WOW_MOD_FREQ / SAMPLES_PER_SEC);
float wow_mod = fastsincos(u32_to_fraction(echo.wow_mod_phase)).sin;
float wow_rate_hz = model.wow_rate * (1.0f + 0.25f * wow_mod);
echo.wow_phase += fraction_to_u32(wow_rate_hz / SAMPLES_PER_SEC);
float wow_depth_s = echo.delay_s * 0.005f * model.wow_scale * echo.wow;
float wow_offset_s = fastsincos(u32_to_fraction(echo.wow_phase)).sin * wow_depth_s;
echo.flutter_phase += fraction_to_u32(model.flutter_rate / SAMPLES_PER_SEC);
float flutter_depth_s = echo.delay_s * 0.0005f * model.flutter_scale * echo.wow;
float flutter_offset_s = fastsincos(u32_to_fraction(echo.flutter_phase)).sin * flutter_depth_s;
float read_pos_s = echo.delay_s + wow_offset_s + flutter_offset_s;
float read_pos = read_pos_s * SAMPLES_PER_SEC;
if (read_pos < 1.0f) read_pos = 1.0f;
if (read_pos > 32766.0f) read_pos = 32766.0f;
// 3. Read
// Read BEFORE write so we get the old loop content. If we wrote first,
// we would read the newly-written sample.
float tape_out = sample_array_read_s16(read_pos, &echo.idx, echo.array);
// 4. Playback LPF (dynamic based on tone pot)
// Tone smooths at coeff 0.0008 -- no need to recompute the pole coefficient
// (which calls pow2) until the value has actually shifted enough to matter.
if (fabsf(echo.tone - echo.last_tone) > 0.001f) {
echo.last_tone = echo.tone;
float eff_playback_fc = echo.tone;
if (eff_playback_fc > 18000.0f) eff_playback_fc = 18000.0f;
echo_onepole_set_cutoff(&echo.playback_lpf, eff_playback_fc);
}
float wet = echo_onepole_step(&echo.playback_lpf, tape_out);
// 5. Feedback path
// Playback signal routes back to the record input. The dedicated feedback
// LPF cuts a bit more high end on every pass around the loop; that's the
// whole reason Echoplex repeats start reasonably bright and gradually
// turn dark and murky. This one filter IS the tape echo sound.
float fb = echo_onepole_step(&echo.feedback_lpf, wet);
// 6. Write
// New signal (record path) mixed with feedback, then soft-clipped.
float record_signal = echo_onepole_step(&echo.record_lpf, preamp_out * echo.record_level);
// In SOS mode, feedback is pegged at 1.0 (erase head bypassed: the loop
// accumulates indefinitely). The SUSTAIN knob is effectively bypassed in
// SOS; RECORD LEVEL controls how aggressively new signal layers over old.
float feedback_amt = echo.sos_mode ? 1.0f : echo.sustain * model.max_feedback;
// tanhf() (rather than limit_value) keeps the loop bounded without
// distorting at sub-saturation levels; see comment at bottom of file.
sample_array_write_s16(tanhf(record_signal + fb * feedback_amt), &echo.idx, echo.array);
// 7. Equal-power blend; keeps total signal power constant across the sweep.
if (fabsf(echo.blend - echo.last_blend) > 0.001f) {
echo.last_blend = echo.blend;
struct sincos gains = fastsincos(echo.blend * 0.25f);
echo.dry_gain = gains.cos;
echo.wet_gain = gains.sin;
}
float out = preamp_out * echo.dry_gain + wet * echo.wet_gain;
// Brighten the status LED in SOS so the player can see they're in
// the indefinite-loop mode.
echo_effect.intense = echo.sos_mode;
return out;
}
//
// tanhf and echo_onepole helpers vs. limit_value and biquad_lpf.
//
// Two stages of the Echoplex signal chain are sensitive to the *shape*
// of the primitive that models them, not just their endpoints. The
// stock helpers are fine general-purpose tools but don't match the
// physical circuit topology closely enough to keep the wet repeats
// in character. If the goal is to sound like an actual tape echo, we
// should use primitives that reasonably match the real-world components.
//
// (1) Waveshaper: preamp colouring, and tape saturation on every write.
//
// A 12AX7 triode at its grid (EP-1, EP-2) and a JFET preamp (EP-3) both
// saturate as soft tanh-shaped curves: a near-linear small-signal region,
// gradual onset of compression, hard ceiling at the rails. The record-head
// bias network + tape saturation on the write operation produce the same
// shape, applied each pass around the loop.
//
// limit_value(x) = x/(1+|x|) doesn't have a linear region. The |x| term
// puts a kink in the second derivative at zero, so even tiny signals get
// harmonically distorted. A clean sustained note through a real 12AX7
// doesn't sound distorted; through limit_value it does. If we hit the
// shaper once in the preamp, then again on every trip around the
// feedback loop, the fuzz compounds.
//
// tanhf is the Padé approximant
//
// x^5 + 105x^3 + 945x
// --------------------
// 15x^4 + 420x^2 + 945
//
// with the output clamped to ±1.
//
// Indistinguishable from tanh below |x|=1 (precise to 6.5 digits),
// 4.7 digits (0.002%) out to ±2
// 3.4 digits (0.040%) out to ±3
// 2.9 digits (0.136%) everywhere
// and an exact slope at zero (so small signals stay clean)
//
// In contrast, the simplistic limit_value() approximation had a 9%
// error already at a value of 0.1. At a 0.1 normalised input a real
// preamp passes audio through almost cleanly; limit_value has already
// trimmed it 9% and added harmonics that the circuit wouldn't create.
// At unity input the gap is 34%, all of it added harmonic content.
// This is audibly noticeable during listening tests. The Pade
// approximation is close enough to the real thing for our purposes,
// and keeps the repeats sounding less woolly and distorted.
//
// (2) Lowpasses: record_lpf, playback_lpf, feedback_lpf.
//
// All three corresponding stages in the hardware are RC networks --
// one resistor, one capacitor, one pole each. None are second-order
// and none resonate:
//
// - record amp into the tape-head bias network: single pole,
// roughly 5-7 kHz depending on model and tape condition.
// - playback head gap loss combined with the playback preamp:
// dominantly first-order above the gap-loss corner; we lump
// the two into one effective one-pole.
// - feedback amplifier: another single pole. The "repeats go
// dark" character is just N applications of this one stage.
//
// biquad_lpf at Q=0.707 is 12 dB/oct regardless of corner. Below the
// corner it's actually flatter than a one-pole, but above the corner
// it cuts at twice the slope. That's the wrong tradeoff here -- the
// clarity of the early repeats comes from the gentle 6 dB/oct stopband
// of an RC.
//
// freq vs. fc biquad (Q=0.707) one-pole
// fc/4 -0.0 dB -0.3 dB
// fc/2 -0.3 dB -1.0 dB
// fc -3.0 dB -3.0 dB
// 2*fc -12 dB -7 dB
// 4*fc -24 dB -12 dB
// 8*fc -36 dB -18 dB
//
// At the corner they agree. An octave above, the biquad is already 5
// dB darker; three octaves above, 18 dB darker. Stacked three deep in
// series, the wet signal loses everything above 2*fc within a couple
// of repeats. Real tape machine echoes don't darken that fast.
//
// (Phase compounds it. A one-pole contributes 45 deg at its corner; a
// biquad contributes 90. Three stages in series gives 135 vs. 270
// degrees of accumulated phase shift each trip around the loop, and
// that smears the wet transients further.)
//
// Cost is incidental but works in our favour. biquad_step is ~5 mul
// + 4 add + a state shuffle per sample; echo_onepole_step is 1 sub
// + 1 mul + 1 add (FMA-friendly).
//