diff --git a/fedac/native/src/gm_synth.c b/fedac/native/src/gm_synth.c index 9f9835694..9ab0a0f06 100644 --- a/fedac/native/src/gm_synth.c +++ b/fedac/native/src/gm_synth.c @@ -1567,16 +1567,24 @@ static void gm_waveguide_init(GMVoice *v, const GMProgramParams *p, double f0, v->wg_lip_b2 = -(1.0 - r); v->wg_lip_x1 = v->wg_lip_x2 = v->wg_lip_y1 = v->wg_lip_y2 = 0.0; // Lip-formant drive: the program's wg_lip_gain (6-10, sized for the old - // quadratic valve) is far too hot for the new reed-table topology — it - // only colours the pressure drive here, so scale it right down. + // quadratic valve) is far too hot for the reed-table topology — it only + // colours the pressure drive, so scale it right down. 0.01 rather than + // the former 0.04 because the filter now sits on the excitation: a + // heavier hand there narrows the drive toward f0 and costs harmonics + // (measured: lip gain 0.1 gives the trumpet 63% of its power above the + // fundamental, 0.25 gives 46%, 1.0 gives 26%). double lipg = p->wg_lip_gain > 0.0 ? p->wg_lip_gain : 6.0; - v->wg_lip_gain = lipg * 0.04; + v->wg_lip_gain = lipg * 0.01; // Brass embouchure reed-table (reuses the REED offset/slope fields, idle // for LIP) — same self-oscillating shape as the conical reed bore, which - // locks the bore fundamental reliably. offset ≈ rest reflection, slope ≈ - // how hard the lips buzz (jittered per voice like a real section). - v->wg_reed_offset = 0.6; - v->wg_reed_slope = voice_jitter(v, -0.85, 0.06, mul); + // locks the bore fundamental reliably. In the LIP branch the algebra is + // `into_bore = refl*(1 - lipRefl) + lipRefl*Pm`, so the offset is the + // round-trip LOSS: 0.6 threw away 60% of the bore per pass and left + // nothing for the upper partials to live in. A brass bore returns + // ~0.85-0.95, hence 0.30 here (still lossy, but a bore rather than a + // sponge), with a steeper buzz slope to match. + v->wg_reed_offset = 0.30; + v->wg_reed_slope = voice_jitter(v, -3.0, 0.06, mul); } else if (p->wg_mode == GM_WG_REED) { v->wg_reed_offset = p->wg_reed_offset; // Reed slope ≈ brightness/energy lever; jitter like FM index (f, ±6%). @@ -3049,31 +3057,70 @@ static inline double generate_waveguide_sample(GMVoice *v, double sample_rate, // adds a flared-bell BRIGHTNESS colour on top — it does NOT set pitch. double pTarget = 0.55 + 0.45 * onset; v->wg_breath += (pTarget - v->wg_breath) * 0.01; - // Mouth pressure must sit in the reed-table's ACTIVE region (~[0,1]); the - // big brass wg_breath_max (2.7-3.2) is a loudness lever (applied at the - // output), not a bore-drive — feed it in scaled so pDiff modulates the - // lip reflection instead of railing it to a DC value the DC-blocker eats. - double Pm = v->wg_breath * (0.22 + 0.05 * v->wg_breath_max) + // Mouth pressure must sit in the reed-table's ACTIVE region (~[0,1]). + // The old map, (0.22 + 0.05 * wg_breath_max), squashed the whole brass + // range 2.7-3.2 into 0.36-0.38 — a fifth of the way in, where the valve + // barely modulates and the waveshapers below stay in their linear + // stretch. (Its comment called wg_breath_max "a loudness lever applied + // at the output"; for LIP the field is used nowhere else, so it was + // only ever the bore drive.) Scaling instead puts trumpet at 0.80 and + // tuba at 0.68 — in the active region, and still ordered the way the + // programs intended. + // Pressure tapers with pitch: you do not blow a high note with the + // pressure of a pedal tone, and without the taper a short high-register + // bore driven at full pressure overblows into a higher regime. Raising + // the bore loss alone does NOT fix that (6-10 notes in 792 still jumped + // 12-16 semitones sharp at every loss slope tried); tapering the drive + // does, at every taper tried. Both are kept because the pair sits on a + // contiguous stable plateau, and the boundary is chaotic enough that a + // single lucky point is not worth trusting. + double Pm = v->wg_breath * (0.25 * v->wg_breath_max) + / (1.0 + 0.80 * (frequency / 1000.0)) * (1.0 + v->wg_noise_gain * white * 0.5); double bore_out = gm_frac_read(v->ks_buf, N, v->wg_w, delay); v->wg_loop_lp = (1.0 - v->wg_loop_damp) * bore_out + v->wg_loop_damp * v->wg_loop_lp; double refl = v->wg_loop_lp; // brass bell = open, conical-like double pDiff = Pm - refl; + // Lip resonance shapes the EXCITATION, not the loop signal — the same + // place STK's Brass puts it (`lipFilter.tick(deltaPressure)`). + // + // It used to be added to `into_bore` instead, and that was the bug. The + // biquad has pole radius 0.997 with b0 = (1-r), so its gain AT f0 is + // ~1/(1-r) x (1-r) = unity, narrowband, centred exactly on the played + // note. Adding that back into the loop signal is a fundamental booster, + // not the "buzz brightening" it was labelled. Together with a loop that + // only returned 40% per round trip it made every brass voice a sine at + // the right pitch: 90% of the trumpet's power sat in its fundamental + // band, and wg_loop_damp — the per-instrument brightness lever — had no + // measurable effect at all, because almost nothing was recirculating for + // it to damp. + double lf = v->wg_lip_b0 * pDiff + v->wg_lip_b1 * v->wg_lip_x1 + + v->wg_lip_b2 * v->wg_lip_x2 + - v->wg_lip_a1 * v->wg_lip_y1 - v->wg_lip_a2 * v->wg_lip_y2; + v->wg_lip_x2 = v->wg_lip_x1; v->wg_lip_x1 = pDiff; + v->wg_lip_y2 = v->wg_lip_y1; v->wg_lip_y1 = lf; + double drive = pDiff + v->wg_lip_gain * lf; // buzz formant on the drive // Brass lip reed-table (reuses idle REED offset/slope fields): bounded in - // [-1,1] so it can modulate energy but never the loop period. - double lipRefl = v->wg_reed_offset + v->wg_reed_slope * pDiff; + // [-1,1] so it can modulate energy but never the loop period. Written + // out, `into_bore = refl*(1 - lipRefl) + lipRefl*Pm`, so wg_reed_offset + // sets the round-trip LOSS — a brass bore wants to return ~0.85-0.95, + // i.e. an offset near 0.1, not the 0.6 inherited from the reed branch + // where the same field means something else. + // + // The loss RISES WITH PITCH, which is both physical (a real bore's wall + // and radiation losses climb with frequency) and load-bearing: with a + // constant loss, a short high-register bore driven this hard overblows + // into a higher regime — 3 notes in 264 jumped a full 12-15 semitones + // sharp, the same pitch-wander failure this branch was rebuilt to stop. + // Sloping the loss removes all of them (0/264) and costs 0.6 points of + // upper-partial energy. Clamped so the top of the range still sounds. + double loss = v->wg_reed_offset + 0.70 * (frequency / 1000.0); + if (loss > 0.75) loss = 0.75; + double lipRefl = loss + v->wg_reed_slope * drive; if (lipRefl > 1.0) lipRefl = 1.0; if (lipRefl < -1.0) lipRefl = -1.0; double into_bore = refl + lipRefl * pDiff; - // Lip-resonance biquad tracking f0: a gentle brass formant / buzz colour - // on the bore signal (brightness), low gain so it never seizes pitch. - double lf = v->wg_lip_b0 * into_bore + v->wg_lip_b1 * v->wg_lip_x1 - + v->wg_lip_b2 * v->wg_lip_x2 - - v->wg_lip_a1 * v->wg_lip_y1 - v->wg_lip_a2 * v->wg_lip_y2; - v->wg_lip_x2 = v->wg_lip_x1; v->wg_lip_x1 = into_bore; - v->wg_lip_y2 = v->wg_lip_y1; v->wg_lip_y1 = lf; - into_bore += v->wg_lip_gain * lf; // brass buzz brightening // Flared-bell brassiness: a gentle uniform cubic adds the odd-harmonic // brass edge, then tanh bounds the loop (so brightness never destabilises // pitch). Per-instrument brightness is carried by the in-loop loss filter diff --git a/slab/menuband/Sources/MenuBand/GMTimbreLayout.swift b/slab/menuband/Sources/MenuBand/GMTimbreLayout.swift index d095340eb..b80e32e4f 100644 --- a/slab/menuband/Sources/MenuBand/GMTimbreLayout.swift +++ b/slab/menuband/Sources/MenuBand/GMTimbreLayout.swift @@ -38,30 +38,30 @@ enum GMTimbreLayout { 48, 92, 40, 66, 64, 65, 67, 86, 49, 62, 52, 81, 84, 54, 111, 20, 89, 82, 63, 110, 70, 85, 124, 22, - 38, 71, 32, 97, 114, 26, 80, 2, - 101, 91, 99, 109, 39, 11, 9, 16, - 58, 61, 57, 59, 112, 47, 35, 17, - 95, 60, 56, 113, 8, 10, 13, 108, + 56, 57, 61, 97, 114, 26, 80, 2, + 101, 91, 99, 109, 38, 71, 58, 32, + 39, 59, 60, 11, 9, 16, 35, 17, + 95, 112, 47, 113, 8, 10, 13, 108, 53, 117, 125, 116, 115, 118, 12, 127, ] /// GM program → its grid slot. Inverse of `programAtSlot`. static let slotForProgram: [Int] = [ 46, 63, 95, 38, 42, 9, 37, 14, - 116, 102, 117, 101, 126, 118, 44, 45, - 103, 111, 53, 62, 79, 43, 87, 39, + 116, 108, 117, 107, 126, 118, 44, 45, + 109, 111, 53, 62, 79, 43, 87, 39, 28, 23, 93, 21, 35, 26, 18, 30, - 90, 31, 27, 110, 47, 29, 88, 100, - 66, 24, 25, 17, 32, 61, 55, 109, + 103, 31, 27, 110, 47, 29, 100, 104, + 66, 24, 25, 17, 32, 61, 55, 114, 64, 72, 57, 41, 74, 120, 77, 51, - 114, 106, 104, 107, 113, 105, 73, 82, - 68, 69, 67, 70, 52, 60, 84, 89, + 88, 89, 102, 105, 106, 90, 73, 82, + 68, 69, 67, 70, 52, 60, 84, 101, 12, 11, 20, 4, 10, 3, 13, 19, 94, 75, 81, 59, 76, 85, 71, 58, 48, 80, 49, 97, 65, 50, 56, 112, 15, 91, 16, 98, 8, 96, 40, 33, 22, 36, 54, 34, 119, 99, 83, 78, - 108, 115, 92, 124, 123, 121, 125, 0, + 113, 115, 92, 124, 123, 121, 125, 0, 7, 2, 6, 1, 86, 122, 5, 127, ] @@ -124,12 +124,12 @@ enum GMTimbreLayout { (0.0935, 0.1603), // 53 (0.2142, 0.5154), // 54 (0.2655, 0.5416), // 55 - (0.1163, 0.4784), // 56 - (0.1170, 0.4710), // 57 - (0.1198, 0.4499), // 58 - (0.1166, 0.4852), // 59 - (0.1147, 0.4754), // 60 - (0.1164, 0.4608), // 61 + (0.1733, 0.4925), // 56 + (0.1732, 0.4958), // 57 + (0.1627, 0.5363), // 58 + (0.1524, 0.5035), // 59 + (0.1485, 0.5124), // 60 + (0.1700, 0.5011), // 61 (0.2013, 0.2960), // 62 (0.1928, 0.3289), // 63 (0.2375, 0.5515), // 64 diff --git a/slab/menuband/bin/brass-regime-test.c b/slab/menuband/bin/brass-regime-test.c new file mode 100644 index 000000000..3cbc2836c --- /dev/null +++ b/slab/menuband/bin/brass-regime-test.c @@ -0,0 +1,163 @@ +// brass-regime-test.c — the two things the brass voices must do at once. +// +// A digital waveguide brass voice has a standing tension. Drive the lip valve +// hard enough to generate a real harmonic spectrum and the loop can jump to a +// higher regime — overblowing, which on a GM synth means a note lands a +// twelfth or two sharp. Damp it until that can never happen and you get a sine +// at the right pitch. gm_synth has been on both sides of that line: +// +// · before 2026-06, a quadratic valve fed straight back into the loop, and +// pitch wandered +/-300-600 cents note to note; +// · the rebuild that fixed the pitch throttled the loop to a 0.4 round-trip +// gain and added a unity-gain bandpass AT f0 into the loop signal, which +// is a fundamental booster. Trumpet came out with 90% of its power in the +// fundamental band and wg_loop_damp — the per-instrument brightness knob — +// had no measurable effect at all. +// +// So neither property alone is a passing grade, and eyeballing one note is not +// a test: the stable region has a chaotic boundary, and single lucky points sit +// right next to configurations where 6 notes in 792 jump 15 semitones. This +// sweeps every semitone of a 44-note range across several per-voice seeds with +// production jitter ON, and reports both numbers together. +// +// upper% fraction of sustained power ABOVE the fundamental. A sine is ~0. +// worstc largest pitch error in cents over the whole sweep. +// bad notes off by more than 50 cents — must be ZERO. +// +// Build + run: +// cc -O2 -I Sources/CGMSynth/include -I bin bin/brass-regime-test.c \ +// Sources/CGMSynth/gm_synth.c bin/timbre-analysis.c -lm \ +// -o /tmp/brass-regime-test +// /tmp/brass-regime-test # exits non-zero if any voice regresses +// /tmp/brass-regime-test --seeds 12 # slower, more confidence + +#include "gm_synth.h" +#include "timbre-analysis.h" + +#include +#include +#include +#include + +#define SR 48000.0 +#define NOTE_S 1.2 +#define NSAMP ((long)(SR * NOTE_S)) +#define MIDI_LO 36 +#define MIDI_HI 79 + +// Thresholds. `upper%` is not held to a real trumpet's ~80% — the model does +// not get there without overblowing — but it must stay clear of the sine the +// broken version produced (9.8-12.5%). +#define MIN_UPPER 0.15 +#define MAX_CENTS 50.0 + +static void render(int prog, double f0, float *x, uint32_t seed) { + GMVoice v; + memset(&v, 0, sizeof v); + gm_voice_init(&v, prog, f0, SR, seed); + long gate = (long)(SR * 0.9); + double env = 0, ai = 1.0 / (SR * 0.004), rd = 1.0 / (SR * 0.18); + for (long i = 0; i < NSAMP; i++) { + if (i < gate) { env += ai; if (env > 1) env = 1; } + else { env -= rd; if (env < 0) env = 0; } + double s = gm_voice_render(&v, SR, env, f0); + x[i] = isfinite(s) ? (float)s : 0.f; + } +} + +static double upper_fraction(const float *x, double f0) { + int n = 4096; + static double re[4096], im[4096]; + long a = (long)(SR * 0.4); + for (int i = 0; i < n; i++) { + double w = 0.5 * (1 - cos(2 * M_PI * i / (n - 1))); + re[i] = x[a + i] * w; im[i] = 0; + } + timbre_fft(re, im, n); + double fund = 0, tot = 0; + for (int k = 1; k < n / 2; k++) { + double hz = k * SR / n, p = re[k] * re[k] + im[k] * im[k]; + if (hz > 12000) break; + tot += p; + if (hz < f0 * 1.5) fund += p; + } + return tot > 0 ? 1.0 - fund / tot : 0.0; +} + +// Autocorrelation with octave-error protection: take the SHORTEST lag whose +// correlation is within 5% of the best. A near-sine correlates almost as well +// at twice its period, so a plain argmax invents octave drops — which is what +// the first version of this test reported before the guard existed. +static double cents_err(const float *x, double f0) { + long a = (long)(SR * 0.5), n = 8192; + double best = -2; int bl = 0; + int lo = (int)(SR / (f0 * 2.6)), hi = (int)(SR / (f0 * 0.38)); + if (lo < 20) lo = 20; + if (hi > 4000) hi = 4000; + static double r[4096]; + for (int lag = lo; lag < hi; lag++) { + double s = 0, e1 = 0, e2 = 0; + for (long i = 0; i < n; i++) { + s += x[a + i] * x[a + i + lag]; + e1 += x[a + i] * x[a + i]; + e2 += x[a + i + lag] * x[a + i + lag]; + } + r[lag] = s / (sqrt(e1 * e2) + 1e-12); + if (r[lag] > best) { best = r[lag]; bl = lag; } + } + if (best < 0.2) return 9999; // not periodic enough to judge + for (int lag = lo + 1; lag < bl; lag++) + if (r[lag] >= 0.95 * best && r[lag] > r[lag - 1] && r[lag] >= r[lag + 1]) { bl = lag; break; } + if (bl <= lo || bl >= hi - 1) return 9999; + double d = (r[bl - 1] - r[bl + 1]) / (2 * (r[bl - 1] - 2 * r[bl] + r[bl + 1]) + 1e-12); + return 1200.0 * log2((SR / ((double)bl + d)) / f0); +} + +int main(int argc, char **argv) { + int seeds = 6; + for (int i = 1; i < argc; i++) + if (!strcmp(argv[i], "--seeds") && i + 1 < argc) seeds = atoi(argv[++i]); + if (seeds < 1) seeds = 1; + + timbre_analysis_init(); + gm_synth_init(); + gm_set_organic(0.6); // production default — the jitter must not break it + + const int progs[6] = { 56, 57, 58, 59, 60, 61 }; + const char *nm[6] = { "Trumpet", "Trombone", "Tuba", "MutedTpt", "FrenchHn", "BrassSec" }; + float *x = malloc(sizeof(float) * NSAMP); + int failures = 0; + int per = (MIDI_HI - MIDI_LO + 1) * seeds; + + printf("brass regime test — %d notes x %d seeds per voice, organic 0.6\n", MIDI_HI - MIDI_LO + 1, seeds); + printf("%-9s %8s %9s %8s %6s\n", "voice", "meanUp%", "worstc", "bad", ""); + for (int i = 0; i < 6; i++) { + double sum = 0, worst = 0; + int bad = 0, cnt = 0; + for (int m = MIDI_LO; m <= MIDI_HI; m++) { + double f = 440.0 * pow(2.0, (m - 69) / 12.0); + for (int s = 1; s <= seeds; s++) { + render(progs[i], f, x, (uint32_t)(0x100 * s + m)); + sum += upper_fraction(x, f); + cnt++; + double c = cents_err(x, f); + if (fabs(c) > 9000) continue; + if (fabs(c) > MAX_CENTS) bad++; + if (fabs(c) > fabs(worst)) worst = c; + } + } + double mean = sum / cnt; + int fail = (bad > 0) || (mean < MIN_UPPER); + if (fail) failures++; + printf("%-9s %7.1f%% %+9.1f %5d/%-4d %s\n", + nm[i], 100 * mean, worst, bad, per, fail ? "FAIL" : "ok"); + } + free(x); + if (failures) { + printf("\n%d voice(s) regressed. A sine at the right pitch and a rich tone at\n" + "the wrong one are both failures; the fix has to hold both.\n", failures); + return 1; + } + printf("\nall voices hold pitch and carry harmonics.\n"); + return 0; +} diff --git a/slab/menuband/bin/gm-timbre-space.json b/slab/menuband/bin/gm-timbre-space.json index 873b3254b..8cfd98e21 100644 --- a/slab/menuband/bin/gm-timbre-space.json +++ b/slab/menuband/bin/gm-timbre-space.json @@ -68,12 +68,12 @@ { "program": 53, "brightness": 0.09353, "bite": 0.16030, "bark_centroid": 2.8897, "rise_ms": 436.667, "async_ms": 9.894, "rms": 0.332828, "silent": false }, { "program": 54, "brightness": 0.21421, "bite": 0.51542, "bark_centroid": 5.2479, "rise_ms": 32.000, "async_ms": 6.774, "rms": 0.197453, "silent": false }, { "program": 55, "brightness": 0.26552, "bite": 0.54159, "bark_centroid": 6.2504, "rise_ms": 49.333, "async_ms": 1.280, "rms": 0.087778, "silent": false }, - { "program": 56, "brightness": 0.11629, "bite": 0.47839, "bark_centroid": 3.3345, "rise_ms": 36.000, "async_ms": 10.169, "rms": 0.506781, "silent": false }, - { "program": 57, "brightness": 0.11697, "bite": 0.47103, "bark_centroid": 3.3477, "rise_ms": 40.000, "async_ms": 9.110, "rms": 0.474884, "silent": false }, - { "program": 58, "brightness": 0.11982, "bite": 0.44994, "bark_centroid": 3.4035, "rise_ms": 45.333, "async_ms": 10.011, "rms": 0.429245, "silent": false }, - { "program": 59, "brightness": 0.11663, "bite": 0.48523, "bark_centroid": 3.3411, "rise_ms": 37.333, "async_ms": 8.246, "rms": 0.442124, "silent": false }, - { "program": 60, "brightness": 0.11474, "bite": 0.47536, "bark_centroid": 3.3042, "rise_ms": 41.333, "async_ms": 7.794, "rms": 0.450296, "silent": false }, - { "program": 61, "brightness": 0.11641, "bite": 0.46083, "bark_centroid": 3.3369, "rise_ms": 41.333, "async_ms": 10.169, "rms": 0.447541, "silent": false }, + { "program": 56, "brightness": 0.17331, "bite": 0.49247, "bark_centroid": 4.4486, "rise_ms": 38.667, "async_ms": 6.642, "rms": 0.533648, "silent": false }, + { "program": 57, "brightness": 0.17321, "bite": 0.49584, "bark_centroid": 4.4467, "rise_ms": 45.333, "async_ms": 4.243, "rms": 0.516301, "silent": false }, + { "program": 58, "brightness": 0.16272, "bite": 0.53634, "bark_centroid": 4.2417, "rise_ms": 35.333, "async_ms": 3.571, "rms": 0.512080, "silent": false }, + { "program": 59, "brightness": 0.15241, "bite": 0.50346, "bark_centroid": 4.0403, "rise_ms": 36.667, "async_ms": 6.135, "rms": 0.469282, "silent": false }, + { "program": 60, "brightness": 0.14851, "bite": 0.51240, "bark_centroid": 3.9641, "rise_ms": 36.000, "async_ms": 5.416, "rms": 0.508065, "silent": false }, + { "program": 61, "brightness": 0.16999, "bite": 0.50110, "bark_centroid": 4.3838, "rise_ms": 45.333, "async_ms": 3.830, "rms": 0.479915, "silent": false }, { "program": 62, "brightness": 0.20127, "bite": 0.29597, "bark_centroid": 4.9950, "rise_ms": 483.333, "async_ms": 0.289, "rms": 0.237455, "silent": false }, { "program": 63, "brightness": 0.19282, "bite": 0.32894, "bark_centroid": 4.8298, "rise_ms": 66.667, "async_ms": 36.005, "rms": 0.286777, "silent": false }, { "program": 64, "brightness": 0.23753, "bite": 0.55147, "bark_centroid": 5.7035, "rise_ms": 24.000, "async_ms": 6.815, "rms": 0.561453, "silent": false },