diff --git a/fedac/native/Makefile b/fedac/native/Makefile index aa80fdd18..a2eb44165 100644 --- a/fedac/native/Makefile +++ b/fedac/native/Makefile @@ -89,6 +89,7 @@ SRCS := $(SRCDIR)/ac-native.c \ $(SRCDIR)/color.c \ $(SRCDIR)/input.c \ $(SRCDIR)/audio.c \ + $(SRCDIR)/gm_synth.c \ $(SRCDIR)/usb-midi.c \ $(SRCDIR)/wifi.c \ $(SRCDIR)/tts.c \ diff --git a/fedac/native/src/audio.c b/fedac/native/src/audio.c index 65f2a293c..216cd4f95 100644 --- a/fedac/native/src/audio.c +++ b/fedac/native/src/audio.c @@ -112,96 +112,11 @@ static inline double clampd(double x, double lo, double hi) { return x; } -// ============================================================ -// Sine wavetable — phase-increment lookup (GM synthesis library) -// ============================================================ -// The repo rule (MEMORY.md "long sine phase-increment not sin(TAU*f*t)") -// is to advance a phase register and read a wavetable, never call sin() -// per sample for sustained tones. The modal-additive GM piano runs up to -// 12 partials per voice × 32 voices, so a table read per partial is far -// cheaper than 12 libm sin() calls. The dossier refers to this as wt_sin. -// Linear-interpolated, single quadrant-symmetric table built once at init. -#define WT_SIN_SIZE 4096 -static float wt_sin_table[WT_SIN_SIZE + 1]; // +1 guard for interp wrap -static int wt_sin_ready = 0; - -static void wt_sin_init(void) { - if (wt_sin_ready) return; - for (int i = 0; i <= WT_SIN_SIZE; i++) { - wt_sin_table[i] = (float)sin(2.0 * M_PI * (double)i / (double)WT_SIN_SIZE); - } - wt_sin_ready = 1; -} - -// Read the sine wavetable at a normalized phase in [0,1). Wraps any phase. -static inline double wt_sin(double phase) { - phase -= (double)(int)phase; // fractional part - if (phase < 0.0) phase += 1.0; - double fpos = phase * (double)WT_SIN_SIZE; - int i0 = (int)fpos; - double f = fpos - (double)i0; - return (double)wt_sin_table[i0] * (1.0 - f) - + (double)wt_sin_table[i0 + 1] * f; -} - -// ============================================================ -// Bounded per-note stochasticism (docs/gm-synthesis/00-stochasticism.md) -// ============================================================ -// All draws come from the voice's per-trigger noise_seed (seeded in -// audio_synth from next_id). Call these ONCE at note-on and bake the -// result into voice params — never per-sample (phase-increment rule). -// The default 0.6 places the amplitude lever inside the percussion -// ±10-25% band; the pitch lever is hard-capped at ±6 cents. - -// Global "organic" amount: 0 = bit-identical, 1 = max tasteful spread. -static double g_organic_amount = 0.6; - -// Uniform [0,1) from the voice PRNG. -static inline double voice_rand_unit(ACVoice *v) { - return (double)xorshift32(&v->noise_seed) / (double)UINT32_MAX; -} - -// Bipolar [-1,1] from the voice PRNG. Workhorse for every jitter lever. -static inline double voice_rand_bipolar(ACVoice *v) { - return voice_rand_unit(v) * 2.0 - 1.0; -} - -// Cents → frequency ratio. 1200 cents = 1 octave. cents_to_ratio(0)==1.0. -static inline double cents_to_ratio(double cents) { - return pow(2.0, cents / 1200.0); -} - -// Bounded multiplicative jitter around `center` by ±`frac` (the percussion -// `rj` idiom): center * (1 ± frac*organic*mul*u). Use for amp, decay, -// attack, cutoff, FM index. Caller picks `frac` from the §4 table. -static inline double voice_jitter(ACVoice *v, double center, - double frac, double mul) { - double u = voice_rand_bipolar(v); - return center * (1.0 + frac * g_organic_amount * mul * u); -} - -// Bounded pitch detune in cents → ratio, HARD-CAPPED at ±6 cents regardless -// of organic/mul so perceived pitch never moves audibly (invariant §4.1). -#define ORGANIC_MAX_CENTS 6.0 -static inline double voice_detune(ACVoice *v, double freq, - double spread_cents, double mul) { - double cents = spread_cents * g_organic_amount * mul * voice_rand_bipolar(v); - if (cents > ORGANIC_MAX_CENTS) cents = ORGANIC_MAX_CENTS; - if (cents < -ORGANIC_MAX_CENTS) cents = -ORGANIC_MAX_CENTS; - return freq * cents_to_ratio(cents); -} - -// Random start phase [0,1) for an additive/modal partial. Decorrelates -// stacked same-notes so they don't phase-cancel. Free + identity-safe. -static inline double voice_rand_phase(ACVoice *v) { - return voice_rand_unit(v); -} - -// Small bounded pan offset (±0.05 spread), added to the voice's base pan. -static inline double voice_pan_jitter(ACVoice *v, double base_pan, double mul) { - double off = 0.05 * g_organic_amount * mul * voice_rand_bipolar(v); - return clampd(base_pan + off, -1.0, 1.0); -} +// The sine wavetable + bounded per-note stochasticism helpers that used to +// live here moved into the standalone gm_synth module (gm_synth.c). The GM +// voices own their own copies; this engine only drives them via +// gm_voice_init()/gm_voice_render(). The global organic knob is set through +// gm_set_organic() (see audio_set_organic below). static inline double compute_envelope(ACVoice *v) { double env = 1.0; @@ -608,333 +523,14 @@ static inline double generate_piano_sample(ACVoice *v, double sample_rate) { // history for "Karplus-Strong" or "Bank/Välimäki" if you need to compare.) // ============================================================ -// GM synthesis library — Family 1: Piano (GM programs 1-8) -// docs/gm-synthesis/01-piano-mallet-organ-guitar.md -// docs/gm-synthesis/00-stochasticism.md +// GM synthesis library (modal piano / FM e-piano / extended Karplus-Strong / +// modal bank / subtractive synth bass) was EXTRACTED to the standalone, +// dependency-free gm_synth module (src/gm_synth.{h,c}) so it can compile + be +// tested off-device (macOS, no ALSA) and later be shared into Menu Band. The +// program tables, GMProgramParams, gm_voice_init(), the generators, and +// gm_program_implemented() all live there now. This engine fills a per-voice +// GMVoice (ACVoice.gm) at note-on and renders it via gm_voice_render(). // ============================================================ -// First vertical slice of the algorithmic General-MIDI voice set. Three -// engines cover the eight Piano-family programs, selected per program by -// gm_voice_init() from the data table below (mirroring the gun_presets[] -// const-table pattern — timbre lives in DATA, not code, so families 2-16 -// extend it the same way): -// -// GM 1-4 Acoustic/Bright/Electric-grand/Honky-tonk → WAVE_GMPIANO -// Modal additive with inharmonic stretched partials, -// f_n = n·f0·sqrt(1 + B·n²) (Fletcher & Rossing 1998, eq. 12.12), -// per-partial exponential decay, a hammer-thump noise burst, and -// (honky-tonk) a second detuned string copy. -// GM 5-6 Electric Piano 1/2 (Rhodes tine / Wurli reed) → WAVE_EPIANO -// 2-operator Chowning FM with an exponentially-decaying index plus a -// high-ratio attack "tine" operator, then an asymmetric-pickup tanh. -// Chowning (1973), JAES 21(7); Pfeifle & Bader (2017), DAFx-17. -// GM 7-8 Harpsichord / Clavi → WAVE_PLUCK -// Extended Karplus-Strong (Jaffe & Smith 1983, CMJ 7(2)) with a -// pluck-position comb, loop-damping LPF, stretch, and (clavi) a -// pickup-bite waveshaper. String delay line reuses whistle_bore_buf. -// -// Bounded note-on stochasticism (dossier 00) is applied ONCE at note-on: -// per-partial amp/decay jitter, pitch detune (hard-capped ±6 cents), random -// start phase, attack jitter. Family mul = 0.8 (pianos), 0.7 (FM), 0.9 -// (plucked) per dossier 00 §6. -typedef struct { - WaveType wave; // which engine renders this program - // -- Modal acoustic-piano params (WAVE_GMPIANO) -- - int partials; // partial count (6-12) - double B; // inharmonicity coefficient (treble grows it) - double partial_tilt; // >1 boosts upper-partial amps (bright pianos) - double tilt_from; // first partial index the tilt applies to - double tau0; // fundamental T60 seconds (treble partials scale down) - double hammer_amp; // hammer-thump mix gain - double hammer_ms; // hammer-thump exp decay (ms) - double dual_cents; // honky-tonk 2nd-copy detune (cents); 0 = single - double drive; // per-voice tanh drive (electric grand); 0 = clean - // -- FM tine/reed params (WAVE_EPIANO) -- - double fm_ratio; // body modulator : carrier ratio - double fm_index0; // initial body modulation index - double fm_index_ms; // index decay time const (ms) → mellowing - double fm_tine_ratio; // high-ratio attack "tine" operator : carrier - double fm_tine_index0; // tine initial index - double fm_tine_ms; // tine index decay (ms) — fast, the attack ping - double fm_pickup; // asymmetric-pickup tanh bias (even-harmonic growl) - // -- Extended-KS params (WAVE_PLUCK) -- - double ks_stretch; // EKS stretch S (<1) — overall decay - double ks_loop_b; // loop-LPF coefficient (brightness; lower=brighter) - double ks_beta; // pluck position (fraction; near-bridge = nasal) - double ks_pick; // pick-position comb mix - double ks_drive; // output tanh drive (clavi bite); 0 = clean -} GMProgramParams; - -#define GM_PIANO_PROGRAM_COUNT 8 -static const GMProgramParams gm_piano_programs[GM_PIANO_PROGRAM_COUNT] = { - // GM 1 — Acoustic Grand: reference modal voice, full partial map. - { .wave = WAVE_GMPIANO, .partials = 10, .B = 0.0009, .partial_tilt = 1.0, - .tilt_from = 0, .tau0 = 9.0, .hammer_amp = 0.18, .hammer_ms = 5.0, - .dual_cents = 0.0, .drive = 0.0 }, - // GM 2 — Bright Acoustic: boost upper partials, harder/shorter hammer. - { .wave = WAVE_GMPIANO, .partials = 11, .B = 0.0010, .partial_tilt = 1.45, - .tilt_from = 4, .tau0 = 9.5, .hammer_amp = 0.24, .hammer_ms = 3.5, - .dual_cents = 0.0, .drive = 0.0 }, - // GM 3 — Electric Grand (CP-70-ish): fewer cleaner partials, half B, - // light tanh drive for the "electrified" edge. - { .wave = WAVE_GMPIANO, .partials = 7, .B = 0.00045, .partial_tilt = 1.1, - .tilt_from = 1, .tau0 = 7.0, .hammer_amp = 0.10, .hammer_ms = 4.0, - .dual_cents = 0.0, .drive = 0.10 }, - // GM 4 — Honky-tonk: dual detuned string copies (~14 cents), more clack. - { .wave = WAVE_GMPIANO, .partials = 9, .B = 0.0011, .partial_tilt = 1.15, - .tilt_from = 2, .tau0 = 6.0, .hammer_amp = 0.26, .hammer_ms = 4.5, - .dual_cents = 14.0, .drive = 0.0 }, - // GM 5 — Electric Piano 1 (Rhodes tine): c:m≈1:1 body + 14:1 tine ping, - // index decays → mellow bell; mild pickup growl. - { .wave = WAVE_EPIANO, .fm_ratio = 1.0, .fm_index0 = 1.2, .fm_index_ms = 700.0, - .fm_tine_ratio = 14.0, .fm_tine_index0 = 0.9, .fm_tine_ms = 18.0, - .fm_pickup = 0.18 }, - // GM 6 — Electric Piano 2 (Wurli reed): c:m≈1:2, stronger asym pickup, - // faster decay, hollow/reedy. - { .wave = WAVE_EPIANO, .fm_ratio = 2.0, .fm_index0 = 1.6, .fm_index_ms = 420.0, - .fm_tine_ratio = 10.0, .fm_tine_index0 = 0.7, .fm_tine_ms = 14.0, - .fm_pickup = 0.34 }, - // GM 7 — Harpsichord: very bright EKS, pluck near bridge (β≈0.13), - // short-ish even decay, the comb is the signature; clean output. - { .wave = WAVE_PLUCK, .ks_stretch = 0.9965, .ks_loop_b = 0.18, - .ks_beta = 0.13, .ks_pick = 0.95, .ks_drive = 0.0 }, - // GM 8 — Clavi: pluck very near the end (β≈0.05), thin bright tone, fast - // decay, tanh pickup bite for the funky electric edge. - { .wave = WAVE_PLUCK, .ks_stretch = 0.990, .ks_loop_b = 0.12, - .ks_beta = 0.05, .ks_pick = 0.9, .ks_drive = 0.5 }, -}; - -// Note-on init for one GM Piano-family program (0-based: 0 = Acoustic Grand). -// Selects v->type, fills the per-voice synthesis state from the program row, -// and applies bounded note-on stochasticism (drawn ONCE here from the voice's -// noise_seed). Programs outside 0..7 return -1 so the caller falls back to the -// normal `type` path. `rng` is unused for now (the voice carries its own -// noise_seed) but is accepted so future families can share an external stream. -static int gm_voice_init(ACVoice *v, int program, double freq, - double sample_rate, uint32_t *rng) { - (void)rng; - if (program < 0 || program >= GM_PIANO_PROGRAM_COUNT) return -1; - const GMProgramParams *p = &gm_piano_programs[program]; - double sr = sample_rate > 0.0 ? sample_rate : (double)AUDIO_SAMPLE_RATE; - double f0 = freq < 20.0 ? 20.0 : freq; - v->gm_program = program; - v->type = p->wave; - - if (p->wave == WAVE_GMPIANO) { - // --- Modal additive with inharmonic stretched partials (GM 1-4) --- - const double mul = 0.8; // dossier 00 §6: acoustic piano - int N = p->partials; - if (N > GM_MAX_PARTIALS) N = GM_MAX_PARTIALS; - if (N < 1) N = 1; - v->p_count = N; - v->gm_dual = (p->dual_cents > 0.0) ? 1 : 0; - v->gm_drive = p->drive; - // Normalize so the partial set sums to a sane peak (1/sqrt(N) keeps - // the additive sum near unity for the velocity-1 case; soft_clip - // catches any residual peak). - double norm = 1.0 / sqrt((double)N); - double sum_check = 0.0; - for (int k = 0; k < N; k++) { - int n = k + 1; // harmonic number (1-based) - // Inharmonic stretch f_n = n·f0·sqrt(1 + B·n²). - double fn = (double)n * f0 * sqrt(1.0 + p->B * (double)(n * n)); - // Per-partial detune (3-string phantom beating) under ±6c cap. - fn = voice_detune(v, fn, 6.0, mul); - if (fn > sr * 0.45) fn = sr * 0.45; // anti-alias guard - v->p_finc[k] = fn / sr; - // Base amplitude: 1/n rolloff, optional upper-partial tilt for - // bright pianos, then ±15% per-partial amp jitter (zero-mean). - double a = norm / (double)n; - if (k >= (int)p->tilt_from) a *= p->partial_tilt; - a = voice_jitter(v, a, 0.15, mul); - v->p_amp[k] = a; - sum_check += a; - // Random start phase decorrelates stacked same-notes (additive - // tone has no transient to keep coherent). - v->p_phase[k] = voice_rand_phase(v); - // Per-partial T60: high partials die first (damping ~ n²), - // with ±15% decay jitter. dec_mult = exp(-1/(tau·sr)). - double tau = p->tau0 / (1.0 + 0.6 * (double)(n - 1)); - tau = voice_jitter(v, tau, 0.15, mul); - if (tau < 0.02) tau = 0.02; - v->p_dec_mult[k] = exp(-1.0 / (tau * sr)); - // Honky-tonk: second string copy, detuned by dual_cents, with - // its own random phase. Phase increment only — no extra amp/decay - // state (shares p_amp[k]/p_dec_mult[k] at render time). - if (v->gm_dual) { - double f2 = fn * cents_to_ratio(p->dual_cents); - if (f2 > sr * 0.45) f2 = sr * 0.45; - v->p2_finc[k] = f2 / sr; - v->p2_phase[k] = voice_rand_phase(v); - } - } - (void)sum_check; - // Hammer thump: short LPF noise burst (set up the envelope; the noise - // itself is consumed per-sample from noise_seed in the render loop). - double htau = (p->hammer_ms * 0.001); - if (htau < 0.0005) htau = 0.0005; - v->gm_hammer_amp = voice_jitter(v, p->hammer_amp, 0.15, mul); - v->gm_hammer_env = 1.0; - v->gm_hammer_dec = exp(-1.0 / (htau * sr)); - v->gm_hammer_lp = 0.0; - } else if (p->wave == WAVE_EPIANO) { - // --- FM tine/reed (Chowning), GM 5-6 --- - const double mul = 0.7; // dossier 00 §6: electric piano - // Carrier at f0, body modulator at fm_ratio·f0. Ratio gets a tiny - // (±2c spread) detune — an FM ratio error IS a sideband detune, so - // it rides the pitch ceiling, not the amplitude lever. - double fc = voice_detune(v, f0, 6.0, mul); - double fm = voice_detune(v, f0 * p->fm_ratio, 2.0, mul); - double ft = f0 * p->fm_tine_ratio; - if (fc > sr * 0.45) fc = sr * 0.45; - v->fm_cphase = 0.0; v->fm_cinc = fc / sr; - v->fm_mphase = 0.0; v->fm_minc = fm / sr; - v->fm_tphase = 0.0; v->fm_tinc = ft / sr; - // Index jitter ≈ the brightness/energy lever (±6%). - v->fm_index = voice_jitter(v, p->fm_index0, 0.06, mul); - v->fm_tindex = voice_jitter(v, p->fm_tine_index0, 0.06, mul); - double idec = p->fm_index_ms * 0.001; if (idec < 0.001) idec = 0.001; - double tdec = p->fm_tine_ms * 0.001; if (tdec < 0.0005) tdec = 0.0005; - v->fm_index_dec = exp(-1.0 / (idec * sr)); - v->fm_tindex_dec = exp(-1.0 / (tdec * sr)); - v->fm_pickup_bias = p->fm_pickup; - v->fm_hp_x1 = 0.0; v->fm_hp_y1 = 0.0; - } else if (p->wave == WAVE_PLUCK) { - // --- Extended Karplus-Strong (harpsichord/clavi), GM 7-8 --- - const double mul = 0.9; // dossier 00 §6: plucked - v->harp_lp1 = 0.0; - v->ks_stretch = p->ks_stretch; - v->ks_loop_b = p->ks_loop_b; - // Pluck position β with ±8% jitter (player doesn't hit the same spot). - v->ks_beta = voice_jitter(v, p->ks_beta, 0.08, mul); - v->ks_pick_amt = p->ks_pick; - v->ks_drive = p->ks_drive; - // Seed the string delay line with one wavelength of bright noise. - // The excitation seed (noise_seed, fresh per trigger) IS the per-pluck - // organic variation. Harpsichord = bright (less pre-smoothing) than - // the nylon harp; we do a single light smoothing pass. - memset(v->whistle_bore_buf, 0, sizeof(v->whistle_bore_buf)); - double string_delay = sr / f0; - const int STRING_N = 2048; - if (string_delay > (double)(STRING_N - 2)) string_delay = (double)(STRING_N - 2); - if (string_delay < 2.0) string_delay = 2.0; - int n = (int)string_delay; - double last = 0.0; - for (int i = 0; i < n; i++) { - double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; - // Light one-pole smoothing (brighter than the harp's 0.5/0.5). - double filt = 0.75 * white + 0.25 * last; - last = white; - v->whistle_bore_buf[i] = (float)filt; - } - v->whistle_bore_w = n; - } - return 0; -} - -// ── GM acoustic-piano render: modal additive + inharmonicity + hammer ── -// (docs/gm-synthesis/01, GM 1-4). Phase-increment wavetable sines only. -static inline double generate_gmpiano_sample(ACVoice *v, double sample_rate) { - (void)sample_rate; - double env = compute_envelope(v); - double s = 0.0; - int N = v->p_count; - for (int k = 0; k < N; k++) { - double a = v->p_amp[k]; - s += a * wt_sin(v->p_phase[k]); - v->p_phase[k] += v->p_finc[k]; - if (v->p_phase[k] >= 1.0) v->p_phase[k] -= 1.0; - if (v->gm_dual) { - // Second detuned string copy (honky-tonk) shares this partial's - // amplitude; its slightly different frequency beats with the first. - s += a * wt_sin(v->p2_phase[k]); - v->p2_phase[k] += v->p2_finc[k]; - if (v->p2_phase[k] >= 1.0) v->p2_phase[k] -= 1.0; - } - v->p_amp[k] *= v->p_dec_mult[k]; // exp decay; high partials die first - } - if (v->gm_dual) s *= 0.5; // two copies → keep level normalized - // Hammer thump: short LPF white-noise burst, exp-decaying envelope. - if (v->gm_hammer_env > 0.0001) { - double white = ((double)xorshift32(&v->noise_seed) / (double)UINT32_MAX) * 2.0 - 1.0; - // 1-pole LPF (~ felt softness) — leak 0.2 of new sample in. - v->gm_hammer_lp = 0.2 * white + 0.8 * v->gm_hammer_lp; - s += v->gm_hammer_amp * v->gm_hammer_env * v->gm_hammer_lp; - v->gm_hammer_env *= v->gm_hammer_dec; - } - // Per-voice tanh drive for the "electrified" grand (GM 3); clean for others. - if (v->gm_drive > 0.0) { - double pre = 1.0 + 4.0 * v->gm_drive; - s = tanh(pre * s) * (1.0 / pre) * (1.0 + v->gm_drive); - } - return s * env; -} - -// ── GM electric-piano render: 2-op FM tine + attack tine + pickup tanh ── -// (docs/gm-synthesis/01, GM 5-6). Chowning FM, wavetable-sine operators. -static inline double generate_epiano_sample(ACVoice *v, double sample_rate) { - (void)sample_rate; - double env = compute_envelope(v); - // Body modulator (index decays → bell-like mellowing) + high-ratio tine - // operator (fast-decaying index → the metallic attack "ping"). - double bodymod = wt_sin(v->fm_mphase) * v->fm_index; - double tinemod = wt_sin(v->fm_tphase) * v->fm_tindex; - double car = wt_sin(v->fm_cphase + bodymod + tinemod); - v->fm_cphase += v->fm_cinc; if (v->fm_cphase >= 1.0) v->fm_cphase -= 1.0; - v->fm_mphase += v->fm_minc; if (v->fm_mphase >= 1.0) v->fm_mphase -= 1.0; - v->fm_tphase += v->fm_tinc; if (v->fm_tphase >= 1.0) v->fm_tphase -= 1.0; - v->fm_index *= v->fm_index_dec; - v->fm_tindex *= v->fm_tindex_dec; - // Asymmetric pickup nonlinearity — biased tanh injects even harmonics - // (the Rhodes/Wurli "growl"), then a DC blocker removes the bias the - // bias term would otherwise pump into the output. - double x = car; - if (v->fm_pickup_bias > 0.0) { - double biased = tanh(x + v->fm_pickup_bias); - double y = biased - v->fm_hp_x1 + 0.999 * v->fm_hp_y1; // 1-pole DC block - v->fm_hp_x1 = biased; - v->fm_hp_y1 = y; - x = y; - } - return x * env; -} - -// ── GM plucked render: extended Karplus-Strong (harpsichord/clavi) ── -// (docs/gm-synthesis/01, GM 7-8). Reuses whistle_bore_buf string delay, -// adds pluck-position comb, loop-damping LPF, stretch, and pickup drive. -static inline double generate_pluck_sample(ACVoice *v, double sample_rate) { - double env = compute_envelope(v); - double freq = clampd(v->frequency, 50.0, sample_rate * 0.20); - double string_delay = sample_rate / freq; - const int STRING_N = 2048; - if (string_delay > (double)(STRING_N - 2)) string_delay = (double)(STRING_N - 2); - if (string_delay < 2.0) string_delay = 2.0; - - // Delayed sample, one wavelength behind (fractional read). - double delayed = whistle_frac_read(v->whistle_bore_buf, STRING_N, - v->whistle_bore_w, string_delay); - // Pick-position comb (Jaffe-Smith H_β): subtract a tap at β·N → the - // bright nasal notches that are the harpsichord/clavi signature. - double tap_delay = v->ks_beta * string_delay; - if (tap_delay < 1.0) tap_delay = 1.0; - double picked = delayed - v->ks_pick_amt * - whistle_frac_read(v->whistle_bore_buf, STRING_N, v->whistle_bore_w, tap_delay); - // Loop damping (one-pole LPF; higher ks_loop_b = darker). harp_lp1 holds - // the filter state across samples. - double damp = (1.0 - v->ks_loop_b) * picked + v->ks_loop_b * v->harp_lp1; - v->harp_lp1 = damp; - // Stretch S (<1) sets overall decay. - double y = v->ks_stretch * damp; - v->whistle_bore_buf[v->whistle_bore_w] = (float)y; - v->whistle_bore_w = (v->whistle_bore_w + 1) % STRING_N; - // Output bite — clavi pickup waveshaper (clean for harpsichord). - double out = y; - if (v->ks_drive > 0.0) { - double pre = 1.0 + 4.0 * v->ks_drive; - out = tanh(pre * y) * (1.0 / pre) * (1.0 + v->ks_drive); - } - // Same loudness boost as the harp (K-S circulating amplitude is low). - return 2.5 * out * env; -} // ============================================================ // One-shot named-buffer bank (zoo / lasers / future kits) @@ -1927,13 +1523,15 @@ static inline double generate_sample(ACVoice *v, double sample_rate) { s = generate_piano_sample(v, sample_rate); break; case WAVE_GMPIANO: - s = generate_gmpiano_sample(v, sample_rate); - break; case WAVE_EPIANO: - s = generate_epiano_sample(v, sample_rate); - break; case WAVE_PLUCK: - s = generate_pluck_sample(v, sample_rate); + case WAVE_MODAL: + case WAVE_SYNTHBASS: + // All GM voices render through the standalone gm_synth module. The + // engine supplies the amplitude envelope + current (smoothed) frequency; + // gm_synth owns the rest of the DSP state (ACVoice.gm) and dispatches on + // its own stored engine type. + s = gm_voice_render(&v->gm, sample_rate, compute_envelope(v), v->frequency); break; default: s = 0.0; @@ -1945,10 +1543,11 @@ static inline double generate_sample(ACVoice *v, double sample_rate) { } // Advance phase for basic oscillators; whistle/gun/harp/piano and the GM - // synthesis voices (gmpiano/epiano/pluck) manage their own phase state. + // synthesis voices (gmpiano/epiano/pluck/modal/synthbass) manage their own + // phase state internally. if (v->type != WAVE_WHISTLE && v->type != WAVE_GUN && v->type != WAVE_HARP && v->type != WAVE_PIANO && v->type != WAVE_GMPIANO && v->type != WAVE_EPIANO && - v->type != WAVE_PLUCK) { + v->type != WAVE_PLUCK && v->type != WAVE_MODAL && v->type != WAVE_SYNTHBASS) { v->phase += v->frequency / sample_rate; if (v->phase >= 1.0) v->phase -= 1.0; } @@ -2831,7 +2430,7 @@ ACAudio *audio_init(void) { pthread_mutex_init(&audio->lock, NULL); // Build the sine wavetable used by the GM modal/FM voices (idempotent). - wt_sin_init(); + gm_synth_init(); // Load piano sample bank from /samples/piano/. Idempotent: safe to // call from both audio_init paths in ac-native.c. Bank is global @@ -3793,7 +3392,8 @@ uint64_t audio_synth(ACAudio *audio, WaveType type, double freq, if (type == WAVE_NOISE || type == WAVE_WHISTLE || type == WAVE_GUN || type == WAVE_HARP || type == WAVE_PIANO || - type == WAVE_GMPIANO || type == WAVE_EPIANO || type == WAVE_PLUCK) { + type == WAVE_GMPIANO || type == WAVE_EPIANO || type == WAVE_PLUCK || + type == WAVE_MODAL || type == WAVE_SYNTHBASS) { v->noise_seed = (uint32_t)(audio->next_id * 2654435761u); } if (type == WAVE_NOISE) { @@ -3885,18 +3485,24 @@ uint64_t audio_synth(ACAudio *audio, WaveType type, double freq, return v->id; } -// GM synthesis voice (docs/gm-synthesis/01). Mirrors audio_synth_gun: do the -// base voice setup (slot alloc, envelope fields, per-trigger noise_seed) via +// gm_program_implemented() now lives in the standalone gm_synth module +// (gm_synth.h / gm_synth.c) — it is the same 0-based program-range table. + +// GM synthesis voice (docs/gm-synthesis/). Mirrors audio_synth_gun: do the base +// voice setup (slot alloc, envelope fields, per-trigger noise_seed) via // audio_synth with a GM placeholder type so the seed is set, then run -// gm_voice_init() to select the real engine + fill its state + apply note-on -// stochasticism. Programs outside 0..7 return 0 so the JS caller can fall -// back to the normal `type`-based audio_synth path. The base init passes -// WAVE_GMPIANO purely to trigger seeding — gm_voice_init overwrites v->type. +// gm_voice_init() to fill the standalone GMVoice (ACVoice.gm) state + apply +// note-on stochasticism. Unimplemented programs return 0 so the JS caller can +// fall back to the normal `type`-based audio_synth path. v->type is kept as one +// of the GM WaveTypes so the render switch routes here and the phase-advance +// guard skips it; the *actual* engine is stored inside v->gm.engine and +// gm_voice_render() dispatches on it. We map the chosen engine back onto +// v->type for parity with the pre-extraction behavior (type checks elsewhere). uint64_t audio_synth_gm(ACAudio *audio, int program, double freq, double duration, double volume, double attack, double decay, double pan) { if (!audio) return 0; - if (program < 0 || program >= GM_PIANO_PROGRAM_COUNT) return 0; + if (!gm_program_implemented(program)) return 0; uint64_t id = audio_synth(audio, WAVE_GMPIANO, freq, duration, volume, attack, decay, pan); if (!id) return 0; @@ -3906,16 +3512,29 @@ uint64_t audio_synth_gm(ACAudio *audio, int program, double freq, for (int i = 0; i < AUDIO_MAX_VOICES; i++) { if (audio->voices[i].id == id) { v = &audio->voices[i]; break; } } + int ok = 1; if (v) { double sr = (double)(audio->actual_rate ? audio->actual_rate : AUDIO_SAMPLE_RATE); - if (gm_voice_init(v, program, freq, sr, &v->noise_seed) < 0) { - // Shouldn't happen (range checked above) — leave as a harmless - // GMPIANO voice rather than a half-init state. - v->type = WAVE_GMPIANO; + if (gm_voice_init(&v->gm, program, freq, sr, v->noise_seed) < 0) { + // gm_program_implemented() said yes but init disagreed — retire the + // slot and report no-voice so the JS caller falls back cleanly. + v->state = VOICE_INACTIVE; + ok = 0; + } else { + // Reflect the chosen engine in v->type (render still routes all GM + // types through gm_voice_render; this keeps type-based guards right). + switch (v->gm.engine) { + case GM_ENGINE_GMPIANO: v->type = WAVE_GMPIANO; break; + case GM_ENGINE_EPIANO: v->type = WAVE_EPIANO; break; + case GM_ENGINE_PLUCK: v->type = WAVE_PLUCK; break; + case GM_ENGINE_MODAL: v->type = WAVE_MODAL; break; + case GM_ENGINE_SYNTHBASS: v->type = WAVE_SYNTHBASS; break; + default: break; // stays WAVE_GMPIANO + } } } pthread_mutex_unlock(&audio->lock); - return id; + return ok ? id : 0; } void audio_kill(ACAudio *audio, uint64_t id, double fade) { @@ -4183,7 +3802,7 @@ void audio_set_drive_mix(ACAudio *audio, float value) { // synthesis. `audio` is accepted for API symmetry but the value lives in a // file-static so the note-on helpers stay dependency-free in the inner code. void audio_set_organic(double amt) { - g_organic_amount = clampd(amt, 0.0, 1.0); + gm_set_organic(clampd(amt, 0.0, 1.0)); } // Wobble / flange dry/wet 0..1. LFO-modulated short delay blended with diff --git a/fedac/native/src/audio.h b/fedac/native/src/audio.h index 51a90109f..87ff4a19b 100644 --- a/fedac/native/src/audio.h +++ b/fedac/native/src/audio.h @@ -5,6 +5,7 @@ #include #include #include "audio-decode.h" +#include "gm_synth.h" // standalone GM voice state (GMVoice) + render API #define AUDIO_SAMPLE_RATE 192000 #define AUDIO_CHANNELS 2 @@ -40,7 +41,21 @@ typedef enum { // the Salamander sample path; these are additive, never a replacement). WAVE_GMPIANO, // modal additive + inharmonicity (acoustic pianos, GM 1-4) WAVE_EPIANO, // FM tine/reed, Chowning (electric pianos, GM 5-6) - WAVE_PLUCK // extended Karplus-Strong (harpsichord/clavi, GM 7-8) + WAVE_PLUCK, // extended Karplus-Strong (harpsichord/clavi, GM 7-8) + // ── GM synthesis batch 2 (dossiers 01/02/04) ── + // WAVE_MODAL — parallel bank of decaying inharmonic sinusoids at measured + // modal ratios. Chromatic Percussion (GM 9-15), Kalimba (109), and the + // pitched metal/wood Percussive family (113-119). Reuses the p_*[] partial + // arrays (phase/amp/finc/dec_mult) as the mode bank — a mode is just a + // partial at an inharmonic ratio with its own exp decay. Struck-coherent + // start phase (no random phase) for a crisp transient (dossier 00 §4c). + // WAVE_SYNTHBASS — subtractive: 1-2 detuned saw/square oscillators (optional + // sub + FM) through a resonant envelope-swept LPF. Synth Bass 1/2 (GM + // 39-40) and the reed-as-saw APPROXIMATIONS for Bagpipe/Fiddle/Shanai + // (110/111/112) pending the waveguide batch. Sustained variants set + // sb_sustain + an optional drone partial. + WAVE_MODAL, + WAVE_SYNTHBASS } WaveType; // Gun voice presets. Two synthesis models are available per preset: @@ -234,44 +249,16 @@ typedef struct { double piano_sample_step; // playback rate (target_f / anchor_f) double piano_sample_amp; // velocity-derived gain // === GM synthesis voice state (docs/gm-synthesis/01 + 00) === - // Shared scratch for the algorithmic GM voices (WAVE_GMPIANO / WAVE_EPIANO - // / WAVE_PLUCK). A voice is only ever one wave type at a time, so these - // unions of purpose are safe to coexist. Filled at note-on by - // gm_voice_init() from the program row + bounded note-on stochasticism. - int gm_program; // GM program 0-7 (for debug / routing) - // -- Modal additive partials (WAVE_GMPIANO; per-partial phase-increment - // wavetable sines with inharmonic stretch + per-partial exp decay). - #define GM_MAX_PARTIALS 12 - int p_count; // active partial count (<= GM_MAX_PARTIALS) - double p_phase[GM_MAX_PARTIALS]; // 0..1 phase accumulators - double p_amp[GM_MAX_PARTIALS]; // current amplitude (decays each sample) - double p_finc[GM_MAX_PARTIALS]; // per-sample phase increment (f_n / sr) - double p_dec_mult[GM_MAX_PARTIALS]; // per-sample exp decay multiplier - int gm_dual; // 1 = honky-tonk: render a 2nd detuned set - double p2_phase[GM_MAX_PARTIALS]; // 2nd (detuned) string copy phases - double p2_finc[GM_MAX_PARTIALS]; // 2nd copy phase increments - double gm_drive; // per-voice tanh drive (electric grand) - double gm_hammer_env; // hammer-thump noise burst envelope - double gm_hammer_dec; // per-sample hammer decay multiplier - double gm_hammer_amp; // hammer mix gain - double gm_hammer_lp; // 1-pole LPF state for hammer noise - // -- FM tine/reed operators (WAVE_EPIANO; 2-op carrier+modulator plus a - // high-ratio attack "tine" operator, all wavetable sines). -- - double fm_cphase, fm_cinc; // carrier phase + increment - double fm_mphase, fm_minc; // body modulator phase + increment - double fm_index, fm_index_dec; // body mod index + per-sample decay - double fm_tphase, fm_tinc; // tine (high-ratio) operator phase + inc - double fm_tindex, fm_tindex_dec; // tine index + per-sample decay - double fm_pickup_bias; // asymmetric-pickup tanh DC bias (growl) - double fm_hp_x1, fm_hp_y1; // DC blocker after the pickup nonlinearity - // -- Extended Karplus-Strong (WAVE_PLUCK; harpsichord/clavi). String delay - // line reuses whistle_bore_buf like WAVE_HARP. These add the - // pick-position comb, loop damping, stretch + output waveshaper. -- - double ks_stretch; // EKS stretch S (<1) — decay control - double ks_loop_b; // one-pole loop LPF coefficient (damping) - double ks_beta; // pluck position (fraction of string len) - double ks_pick_amt; // pick-position comb mix - double ks_drive; // output tanh drive (clavi pickup bite) + // The entire algorithmic GM voice set (modal piano / FM e-piano / extended + // Karplus-Strong pluck / modal bank / subtractive synth bass) now lives in + // the standalone, dependency-free gm_synth module so it can compile + be + // tested off-device (macOS, no ALSA) and later be shared into Menu Band. + // GMVoice owns ALL the per-voice GM DSP state — partial arrays, FM ops, KS + // string + its own delay buffers, modal bank, subtractive filter, its own + // attack/secondary noise biquad+envelopes, and its own xorshift seed. The + // engine fills it at note-on via gm_voice_init() and renders via + // gm_voice_render(); see gm_synth.h. + GMVoice gm; } ACVoice; typedef struct { @@ -483,12 +470,14 @@ uint64_t audio_synth_gun(ACAudio *audio, GunPreset preset, double duration, void audio_gun_voice_set_param(ACAudio *audio, uint64_t id, const char *key, double value); -// Add a GM-synthesis voice for Piano-family program 0-7 (0 = Acoustic Grand). -// Selects the algorithmic engine (modal piano / FM e-piano / extended-KS -// pluck) and applies bounded note-on stochasticism. Returns 0 (no voice) for -// programs outside 0-7 so the caller can fall back to audio_synth() with the -// requested `type`. See gm_voice_init() + gm_piano_programs[] in audio.c and -// docs/gm-synthesis/01-piano-mallet-organ-guitar.md. +// Add a GM-synthesis voice for an implemented General-MIDI program (0-based; +// 0 = Acoustic Grand). Batch 1: Piano family 0-7. Batch 2: Chromatic +// Percussion 8-15, Guitar 24-31, Bass 32-39, Ethnic 104-111, Percussive +// 112-119. Selects the algorithmic engine (modal piano / FM e-piano / +// extended-KS pluck / modal bank / subtractive) and applies bounded note-on +// stochasticism. Returns 0 (no voice) for unimplemented programs so the caller +// can fall back to audio_synth() with the requested `type`. See gm_voice_init() +// + the gm_*_programs[] tables in audio.c and docs/gm-synthesis/. uint64_t audio_synth_gm(ACAudio *audio, int program, double freq, double duration, double volume, double attack, double decay, double pan); diff --git a/fedac/native/src/gm_synth.c b/fedac/native/src/gm_synth.c new file mode 100644 index 000000000..0c8185fb4 --- /dev/null +++ b/fedac/native/src/gm_synth.c @@ -0,0 +1,1031 @@ +// gm_synth.c — Standalone General-MIDI synthesis (see gm_synth.h). +// +// Extracted verbatim from audio.c. The DSP math is unchanged; the only edits are +// mechanical decoupling renames so the module owns its own state: +// ACVoice* v → GMVoice* v +// v->noise_seed → v->rng_seed (own RNG stream) +// v->whistle_bore_buf → v->bore_buf (own short KS delay line) +// v->whistle_bore_w → v->bore_w +// v->noise_b0..a2 → v->nb0..na2 (own attack-burst biquad) +// v->noise_x1..y2 → v->nx1..ny2 +// v->gun_click_env → v->atk_env (own attack-burst envelope) +// v->gun_click_decay_mult → v->atk_dec +// v->gun_secondary_trig → v->sec_trig (own secondary-excitation timer) +// v->gun_secondary_amp → v->sec_amp +// v->type = p->wave → v->engine = GM_ENGINE_* +// compute_envelope(v) → passed-in `env` +// v->frequency → passed-in `frequency` +// AUDIO_SAMPLE_RATE fallback is inlined as GM_FALLBACK_SR. + +#include "gm_synth.h" + +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +// Sample-rate fallback when a caller passes <= 0 (was AUDIO_SAMPLE_RATE). +#define GM_FALLBACK_SR 192000.0 + +// ============================================================ +// PRNG + small math helpers (copied from audio.c) +// ============================================================ +static inline uint32_t xorshift32(uint32_t *state) { + uint32_t x = *state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *state = x; + return x; +} + +static inline double clampd(double x, double lo, double hi) { + if (x < lo) return lo; + if (x > hi) return hi; + return x; +} + +// ============================================================ +// Sine wavetable — phase-increment lookup (GM synthesis library) +// ============================================================ +#define WT_SIN_SIZE 4096 +static float wt_sin_table[WT_SIN_SIZE + 1]; // +1 guard for interp wrap +static int wt_sin_ready = 0; + +void gm_synth_init(void) { + if (wt_sin_ready) return; + for (int i = 0; i <= WT_SIN_SIZE; i++) { + wt_sin_table[i] = (float)sin(2.0 * M_PI * (double)i / (double)WT_SIN_SIZE); + } + wt_sin_ready = 1; +} + +// Read the sine wavetable at a normalized phase in [0,1). Wraps any phase. +static inline double wt_sin(double phase) { + phase -= (double)(int)phase; // fractional part + if (phase < 0.0) phase += 1.0; + double fpos = phase * (double)WT_SIN_SIZE; + int i0 = (int)fpos; + double f = fpos - (double)i0; + return (double)wt_sin_table[i0] * (1.0 - f) + + (double)wt_sin_table[i0 + 1] * f; +} + +// ============================================================ +// Bounded per-note stochasticism (docs/gm-synthesis/00-stochasticism.md) +// ============================================================ +static double g_organic_amount = 0.6; + +void gm_set_organic(double amt) { g_organic_amount = amt; } + +// Uniform [0,1) from the voice PRNG. +static inline double voice_rand_unit(GMVoice *v) { + return (double)xorshift32(&v->rng_seed) / (double)UINT32_MAX; +} + +// Bipolar [-1,1] from the voice PRNG. Workhorse for every jitter lever. +static inline double voice_rand_bipolar(GMVoice *v) { + return voice_rand_unit(v) * 2.0 - 1.0; +} + +// Cents → frequency ratio. 1200 cents = 1 octave. cents_to_ratio(0)==1.0. +static inline double cents_to_ratio(double cents) { + return pow(2.0, cents / 1200.0); +} + +// Bounded multiplicative jitter around `center` by ±`frac`. +static inline double voice_jitter(GMVoice *v, double center, + double frac, double mul) { + double u = voice_rand_bipolar(v); + return center * (1.0 + frac * g_organic_amount * mul * u); +} + +// Bounded pitch detune in cents → ratio, HARD-CAPPED at ±6 cents. +#define ORGANIC_MAX_CENTS 6.0 +static inline double voice_detune(GMVoice *v, double freq, + double spread_cents, double mul) { + double cents = spread_cents * g_organic_amount * mul * voice_rand_bipolar(v); + if (cents > ORGANIC_MAX_CENTS) cents = ORGANIC_MAX_CENTS; + if (cents < -ORGANIC_MAX_CENTS) cents = -ORGANIC_MAX_CENTS; + return freq * cents_to_ratio(cents); +} + +// Random start phase [0,1) for an additive/modal partial. +static inline double voice_rand_phase(GMVoice *v) { + return voice_rand_unit(v); +} + +// Fractional-delay read from a ring buffer. +static inline double gm_frac_read(const float *buf, int N, int w, double delay) { + if (delay < 0.0) delay = 0.0; + if (delay > (double)(N - 2)) delay = (double)(N - 2); + double rd = (double)w - delay; + while (rd < 0.0) rd += (double)N; + int i0 = (int)rd; + int i1 = (i0 + 1) % N; + double f = rd - (double)i0; + return (double)buf[i0] * (1.0 - f) + (double)buf[i1] * f; +} + +// ============================================================ +// GM synthesis library — Family 1: Piano (GM programs 1-8) +// ============================================================ +typedef struct { + GMEngine engine; // which engine renders this program + // -- Modal acoustic-piano params (GMPIANO) -- + int partials; + double B; + double partial_tilt; + double tilt_from; + double tau0; + double hammer_amp; + double hammer_ms; + double dual_cents; + double drive; + // -- FM tine/reed params (EPIANO) -- + double fm_ratio; + double fm_index0; + double fm_index_ms; + double fm_tine_ratio; + double fm_tine_index0; + double fm_tine_ms; + double fm_pickup; + // -- Extended-KS params (PLUCK) -- + double ks_stretch; + double ks_loop_b; + double ks_beta; + double ks_pick; + double ks_drive; + // -- Extended-KS batch-2 params (guitar / bass / ethnic plucked) -- + int ks_big; + int ks_hard; + double ks_exc_smooth; + double ks_jawari; + double ks_attack_amp; + double ks_attack_ms; + double ks_attack_bp; + double ks_sec_ms; + double ks_sec_amp; + double bodyf[3]; + double bodyq[3]; + double bodyg[3]; + // -- Subtractive synth-bass / reed-as-saw params (SYNTHBASS) -- + int sb_o2_sq; + double sb_o2_cents; + double sb_o2_mix; + double sb_sub; + double sb_fm0; + double sb_fm_ms; + double sb_cut0; + double sb_cut1; + double sb_cut_ms; + double sb_res; + double sb_psweep; + double sb_psweep_ms; + int sb_sustained; + double sb_drone_mix; + double sb_breath; + double sb_vib_hz; + double sb_vib_depth; +} GMProgramParams; + +#define GM_PIANO_PROGRAM_COUNT 8 +static const GMProgramParams gm_piano_programs[GM_PIANO_PROGRAM_COUNT] = { + // GM 1 — Acoustic Grand + { .engine = GM_ENGINE_GMPIANO, .partials = 10, .B = 0.0009, .partial_tilt = 1.0, + .tilt_from = 0, .tau0 = 9.0, .hammer_amp = 0.18, .hammer_ms = 5.0, + .dual_cents = 0.0, .drive = 0.0 }, + // GM 2 — Bright Acoustic + { .engine = GM_ENGINE_GMPIANO, .partials = 11, .B = 0.0010, .partial_tilt = 1.45, + .tilt_from = 4, .tau0 = 9.5, .hammer_amp = 0.24, .hammer_ms = 3.5, + .dual_cents = 0.0, .drive = 0.0 }, + // GM 3 — Electric Grand + { .engine = GM_ENGINE_GMPIANO, .partials = 7, .B = 0.00045, .partial_tilt = 1.1, + .tilt_from = 1, .tau0 = 7.0, .hammer_amp = 0.10, .hammer_ms = 4.0, + .dual_cents = 0.0, .drive = 0.10 }, + // GM 4 — Honky-tonk + { .engine = GM_ENGINE_GMPIANO, .partials = 9, .B = 0.0011, .partial_tilt = 1.15, + .tilt_from = 2, .tau0 = 6.0, .hammer_amp = 0.26, .hammer_ms = 4.5, + .dual_cents = 14.0, .drive = 0.0 }, + // GM 5 — Electric Piano 1 (Rhodes tine) + { .engine = GM_ENGINE_EPIANO, .fm_ratio = 1.0, .fm_index0 = 1.2, .fm_index_ms = 700.0, + .fm_tine_ratio = 14.0, .fm_tine_index0 = 0.9, .fm_tine_ms = 18.0, + .fm_pickup = 0.18 }, + // GM 6 — Electric Piano 2 (Wurli reed) + { .engine = GM_ENGINE_EPIANO, .fm_ratio = 2.0, .fm_index0 = 1.6, .fm_index_ms = 420.0, + .fm_tine_ratio = 10.0, .fm_tine_index0 = 0.7, .fm_tine_ms = 14.0, + .fm_pickup = 0.34 }, + // GM 7 — Harpsichord + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9965, .ks_loop_b = 0.18, + .ks_beta = 0.13, .ks_pick = 0.95, .ks_drive = 0.0 }, + // GM 8 — Clavi + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.990, .ks_loop_b = 0.12, + .ks_beta = 0.05, .ks_pick = 0.9, .ks_drive = 0.5 }, +}; + +// ── Modal bank (MODAL) ── +#define GM_MODAL_MAX_MODES 8 +typedef struct { + const char *name; + int nmodes; + double ratio[GM_MODAL_MAX_MODES]; + double amp[GM_MODAL_MAX_MODES]; + double t60[GM_MODAL_MAX_MODES]; + double strike_amp; + double strike_ms; + double trem_hz; + double trem_depth; + double bloom; + int pitched; +} GMModalParams; + +// Chromatic Percussion (GM 9-15) +#define GM_CHROMPERC_FIRST 8 +#define GM_CHROMPERC_COUNT 7 +static const GMModalParams gm_chromperc_programs[GM_CHROMPERC_COUNT] = { + { .name = "celesta", .nmodes = 3, .ratio = {1.0, 4.0, 10.8}, + .amp = {1.0, 0.30, 0.10}, .t60 = {1.6, 0.6, 0.25}, + .strike_amp = 0.06, .strike_ms = 2.5, .pitched = 1 }, + { .name = "glockenspiel", .nmodes = 4, .ratio = {1.0, 2.76, 5.40, 8.90}, + .amp = {1.0, 0.55, 0.32, 0.18}, .t60 = {2.2, 0.45, 0.25, 0.15}, + .strike_amp = 0.14, .strike_ms = 1.8, .pitched = 1 }, + { .name = "musicbox", .nmodes = 3, .ratio = {1.0, 6.27, 17.55}, + .amp = {1.0, 0.22, 0.08}, .t60 = {1.4, 0.4, 0.18}, + .strike_amp = 0.05, .strike_ms = 2.0, .pitched = 1 }, + { .name = "vibraphone", .nmodes = 3, .ratio = {1.0, 4.0, 9.6}, + .amp = {1.0, 0.35, 0.12}, .t60 = {5.0, 1.6, 0.8}, + .strike_amp = 0.04, .strike_ms = 3.0, .trem_hz = 5.0, .trem_depth = 0.3, + .pitched = 1 }, + { .name = "marimba", .nmodes = 3, .ratio = {1.0, 4.0, 9.2}, + .amp = {1.0, 0.22, 0.08}, .t60 = {0.9, 0.35, 0.18}, + .strike_amp = 0.06, .strike_ms = 2.5, .pitched = 1 }, + { .name = "xylophone", .nmodes = 3, .ratio = {1.0, 3.0, 6.0}, + .amp = {1.0, 0.45, 0.20}, .t60 = {0.55, 0.25, 0.14}, + .strike_amp = 0.12, .strike_ms = 1.8, .pitched = 1 }, + { .name = "tubularbells", .nmodes = 6, + .ratio = {2.0, 3.0, 4.16, 5.43, 6.79, 8.21}, + .amp = {1.0, 0.7, 0.5, 0.35, 0.22, 0.14}, + .t60 = {9.0, 7.0, 5.0, 3.5, 2.2, 1.4}, + .strike_amp = 0.16, .strike_ms = 2.2, .pitched = 1 }, +}; + +// Percussive family (GM 113-119) +#define GM_PERC_FIRST 112 +#define GM_PERC_COUNT 7 +static const GMModalParams gm_perc_programs[GM_PERC_COUNT] = { + { .name = "tinklebell", .nmodes = 5, .ratio = {0.5, 1.0, 1.19, 1.5, 2.0}, + .amp = {0.3, 1.0, 0.6, 0.5, 0.45}, .t60 = {0.7, 0.6, 0.4, 0.35, 0.3}, + .strike_amp = 0.10, .strike_ms = 1.5, .pitched = 1 }, + { .name = "agogo", .nmodes = 3, .ratio = {1.0, 1.52, 2.66}, + .amp = {1.0, 0.5, 0.3}, .t60 = {0.4, 0.25, 0.15}, + .strike_amp = 0.14, .strike_ms = 1.2, .pitched = 1 }, + { .name = "steeldrum", .nmodes = 5, .ratio = {1.0, 2.0, 2.6, 3.0, 4.2}, + .amp = {1.0, 0.6, 0.55, 0.4, 0.25}, .t60 = {1.4, 1.0, 0.9, 0.6, 0.4}, + .strike_amp = 0.10, .strike_ms = 2.0, .bloom = 0.25, .pitched = 1 }, + { .name = "woodblock", .nmodes = 3, .ratio = {1.0, 2.7, 5.4}, + .amp = {1.0, 0.4, 0.18}, .t60 = {0.09, 0.05, 0.035}, + .strike_amp = 0.18, .strike_ms = 0.9, .pitched = 1 }, + { .name = "taiko", .nmodes = 5, .ratio = {1.0, 1.59, 2.14, 2.30, 2.65}, + .amp = {1.0, 0.4, 0.25, 0.18, 0.12}, .t60 = {0.55, 0.3, 0.2, 0.16, 0.12}, + .strike_amp = 0.30, .strike_ms = 6.0, .pitched = 1 }, + { .name = "melodictom", .nmodes = 4, .ratio = {1.0, 1.59, 2.14, 2.30}, + .amp = {1.0, 0.3, 0.16, 0.10}, .t60 = {0.7, 0.35, 0.22, 0.16}, + .strike_amp = 0.16, .strike_ms = 4.0, .pitched = 1 }, + { .name = "synthdrum", .nmodes = 2, .ratio = {1.0, 2.0}, + .amp = {1.0, 0.15}, .t60 = {0.35, 0.12}, + .strike_amp = 0.12, .strike_ms = 1.0, .pitched = 1 }, +}; + +// Kalimba (GM 109) +static const GMModalParams gm_kalimba_program = { + .name = "kalimba", .nmodes = 3, .ratio = {1.0, 5.4, 14.7}, + .amp = {1.0, 0.35, 0.12}, .t60 = {0.8, 0.18, 0.06}, + .strike_amp = 0.07, .strike_ms = 2.0, .pitched = 1 +}; + +// ── Guitar family (GM 25-32) ── +#define GM_GUITAR_FIRST 24 +#define GM_GUITAR_COUNT 8 +static const GMProgramParams gm_guitar_programs[GM_GUITAR_COUNT] = { + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9975, .ks_loop_b = 0.30, + .ks_beta = 0.13, .ks_pick = 0.85, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.7, .ks_attack_amp = 0.04, .ks_attack_ms = 4.0, + .bodyf = {100.0, 200.0}, .bodyq = {6.0, 8.0}, .bodyg = {0.12, 0.07} }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9982, .ks_loop_b = 0.16, + .ks_beta = 0.10, .ks_pick = 0.95, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.2, .ks_attack_amp = 0.07, .ks_attack_ms = 2.5, + .bodyf = {110.0, 220.0, 400.0}, .bodyq = {7.0, 9.0, 10.0}, + .bodyg = {0.14, 0.09, 0.05} }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9985, .ks_loop_b = 0.34, + .ks_beta = 0.18, .ks_pick = 0.80, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.45, .ks_attack_amp = 0.05, .ks_attack_ms = 3.0 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9980, .ks_loop_b = 0.14, + .ks_beta = 0.12, .ks_pick = 0.90, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.25, .ks_attack_amp = 0.06, .ks_attack_ms = 2.5 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.985, .ks_loop_b = 0.55, + .ks_beta = 0.12, .ks_pick = 0.90, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.3, .ks_attack_amp = 0.08, .ks_attack_ms = 2.0 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9988, .ks_loop_b = 0.12, + .ks_beta = 0.11, .ks_pick = 0.90, .ks_drive = 0.6, + .ks_big = 1, .ks_exc_smooth = 0.2, .ks_attack_amp = 0.06, .ks_attack_ms = 2.5 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9991, .ks_loop_b = 0.10, + .ks_beta = 0.10, .ks_pick = 0.90, .ks_drive = 1.0, .ks_hard = 1, + .ks_big = 1, .ks_exc_smooth = 0.15, .ks_attack_amp = 0.05, .ks_attack_ms = 2.5 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9990, .ks_loop_b = 0.10, + .ks_beta = 0.5, .ks_pick = 1.0, .ks_drive = 0.2, + .ks_big = 1, .ks_exc_smooth = 0.4, .ks_attack_amp = 0.03, .ks_attack_ms = 2.0 }, +}; + +// ── Bass plucked (GM 33-38) ── +#define GM_BASS_FIRST 32 +#define GM_BASS_PLUCK_COUNT 6 +static const GMProgramParams gm_bass_programs[GM_BASS_PLUCK_COUNT] = { + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.996, .ks_loop_b = 0.45, + .ks_beta = 0.35, .ks_pick = 0.70, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.85, .ks_attack_amp = 0.05, .ks_attack_ms = 6.0, + .bodyf = {100.0}, .bodyq = {5.0}, .bodyg = {0.15} }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.998, .ks_loop_b = 0.28, + .ks_beta = 0.25, .ks_pick = 0.80, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.6, .ks_attack_amp = 0.05, .ks_attack_ms = 4.0 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.997, .ks_loop_b = 0.18, + .ks_beta = 0.12, .ks_pick = 0.92, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.2, .ks_attack_amp = 0.10, .ks_attack_ms = 1.5 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9985, .ks_loop_b = 0.36, + .ks_beta = 0.30, .ks_pick = 0.78, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.7, .ks_attack_amp = 0.04, .ks_attack_ms = 5.0 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.997, .ks_loop_b = 0.20, + .ks_beta = 0.20, .ks_pick = 0.88, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.15, .ks_attack_amp = 0.22, .ks_attack_ms = 5.0, + .ks_attack_bp = 14.0 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.995, .ks_loop_b = 0.16, + .ks_beta = 0.10, .ks_pick = 0.92, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.10, .ks_attack_amp = 0.24, .ks_attack_ms = 3.0, + .ks_attack_bp = 22.0, .ks_sec_ms = 10.0, .ks_sec_amp = 0.5 }, +}; + +// ── Ethnic plucked (GM 105-108) ── +#define GM_ETHNIC_FIRST 104 +static const GMProgramParams gm_ethnic_pluck_programs[4] = { + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9985, .ks_loop_b = 0.14, + .ks_beta = 0.12, .ks_pick = 0.92, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.2, .ks_jawari = 0.5, + .ks_attack_amp = 0.05, .ks_attack_ms = 2.5 }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.992, .ks_loop_b = 0.10, + .ks_beta = 0.14, .ks_pick = 0.92, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.15, .ks_attack_amp = 0.08, .ks_attack_ms = 2.0, + .bodyf = {300.0, 480.0, 720.0}, .bodyq = {5.0, 7.0, 8.0}, + .bodyg = {0.16, 0.10, 0.06} }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.993, .ks_loop_b = 0.18, + .ks_beta = 0.15, .ks_pick = 0.90, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.1, .ks_jawari = 0.2, + .ks_attack_amp = 0.14, .ks_attack_ms = 2.0, + .bodyf = {250.0, 520.0}, .bodyq = {4.0, 6.0}, .bodyg = {0.12, 0.06} }, + { .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9982, .ks_loop_b = 0.26, + .ks_beta = 0.18, .ks_pick = 0.85, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.5, .ks_attack_amp = 0.04, .ks_attack_ms = 3.5 }, +}; + +// ── Subtractive (SYNTHBASS): Synth Bass 1/2 (GM 39-40) + reed approximations ── +typedef struct { + int program; + GMProgramParams p; +} GMSynthBassRow; +static const GMSynthBassRow gm_synthbass_programs[] = { + { 38, { .engine = GM_ENGINE_SYNTHBASS, .sb_o2_cents = -7.0, .sb_o2_mix = 0.7, + .sb_sub = 0.4, .sb_cut0 = 2600.0, .sb_cut1 = 380.0, .sb_cut_ms = 150.0, + .sb_res = 0.55, .sb_psweep = 1.12, .sb_psweep_ms = 10.0, + .sb_sustained = 0 } }, + { 39, { .engine = GM_ENGINE_SYNTHBASS, .sb_o2_cents = 0.0, .sb_o2_mix = 0.6, + .sb_o2_sq = 1, .sb_sub = 0.3, .sb_fm0 = 1.4, .sb_fm_ms = 80.0, + .sb_cut0 = 3200.0, .sb_cut1 = 320.0, .sb_cut_ms = 110.0, + .sb_res = 0.7, .sb_psweep = 1.12, .sb_psweep_ms = 8.0, + .sb_sustained = 0 } }, + { 109, { .engine = GM_ENGINE_SYNTHBASS, .sb_o2_cents = 4.0, .sb_o2_mix = 0.5, + .sb_cut0 = 4200.0, .sb_cut1 = 4200.0, .sb_cut_ms = 5.0, .sb_res = 0.3, + .sb_psweep = 1.0, .sb_sustained = 1, .sb_drone_mix = 0.5, + .sb_breath = 0.10 } }, + { 110, { .engine = GM_ENGINE_SYNTHBASS, .sb_o2_cents = 3.0, .sb_o2_mix = 0.4, + .sb_cut0 = 3200.0, .sb_cut1 = 3000.0, .sb_cut_ms = 40.0, .sb_res = 0.4, + .sb_psweep = 1.0, .sb_sustained = 1, .sb_vib_hz = 5.5, + .sb_vib_depth = 0.006, .sb_breath = 0.04 } }, + { 111, { .engine = GM_ENGINE_SYNTHBASS, .sb_o2_cents = 6.0, .sb_o2_mix = 0.45, + .sb_fm0 = 0.6, .sb_fm_ms = 400.0, .sb_cut0 = 5000.0, .sb_cut1 = 4600.0, + .sb_cut_ms = 30.0, .sb_res = 0.45, .sb_psweep = 1.0, .sb_sustained = 1, + .sb_vib_hz = 6.0, .sb_vib_depth = 0.01, .sb_breath = 0.12 } }, +}; +#define GM_SYNTHBASS_ROWS (int)(sizeof(gm_synthbass_programs)/sizeof(gm_synthbass_programs[0])) + +// ── Batch-2 note-on helpers ── + +// Set up up-to-3 parallel body-resonance band-pass biquads. +static void gm_setup_body_resonance(GMVoice *v, const GMProgramParams *p, + double sr, double mul) { + v->ks_body_n = 0; + for (int i = 0; i < 3; i++) { + if (p->bodyf[i] <= 0.0) break; + double f = p->bodyf[i]; + if (f > sr * 0.45) f = sr * 0.45; + double Q = p->bodyq[i] > 0.1 ? p->bodyq[i] : 1.0; + double w0 = 2.0 * M_PI * f / sr; + double alpha = sin(w0) / (2.0 * Q); + double a0 = 1.0 + alpha; + v->ks_body_a1[i] = (-2.0 * cos(w0)) / a0; + v->ks_body_a2[i] = (1.0 - alpha) / a0; + v->ks_body_g[i] = voice_jitter(v, p->bodyg[i], 0.15, mul) * (alpha / a0); + v->ks_body_y1[i] = 0.0; + v->ks_body_y2[i] = 0.0; + v->ks_body_n = i + 1; + } +} + +// Modal-bank note-on. +static void gm_modal_init(GMVoice *v, const GMModalParams *m, double f0, + double sr) { + const double mul = 1.0; + int N = m->nmodes; + if (N > GM_MAX_PARTIALS) N = GM_MAX_PARTIALS; + if (N > GM_MODAL_MAX_MODES) N = GM_MODAL_MAX_MODES; + if (N < 1) N = 1; + v->p_count = N; + v->gm_dual = 0; + v->gm_drive = 0.0; + v->gm_modal_pitched = m->pitched; + v->gm_modal_bloom = m->bloom; + v->gm_modal_fund = 0.0; + double norm = 0.0; + for (int k = 0; k < N; k++) norm += m->amp[k]; + if (norm < 1e-6) norm = 1.0; + norm = 1.0 / norm; + for (int k = 0; k < N; k++) { + double fk = f0 * m->ratio[k]; + fk = voice_detune(v, fk, 6.0, mul); + if (fk > sr * 0.45) fk = sr * 0.45; + v->p_finc[k] = fk / sr; + v->p_amp[k] = voice_jitter(v, m->amp[k] * norm, 0.15, mul); + v->p_phase[k] = 0.0; + double tau = voice_jitter(v, m->t60[k], 0.15, mul); + if (tau < 0.01) tau = 0.01; + v->p_dec_mult[k] = exp(-1.0 / (tau * sr)); + } + v->gm_trem_depth = m->trem_depth; + v->gm_trem_phase = voice_rand_phase(v); + v->gm_trem_inc = (m->trem_hz > 0.0) ? (m->trem_hz / sr) : 0.0; + double stau = m->strike_ms * 0.001; if (stau < 0.0005) stau = 0.0005; + v->gm_hammer_amp = voice_jitter(v, m->strike_amp, 0.15, mul); + v->gm_hammer_env = 1.0; + v->gm_hammer_dec = exp(-1.0 / (stau * sr)); + v->gm_hammer_lp = 0.0; +} + +// Long-KS note-on (guitar/bass/ethnic). +static void gm_ks_big_init(GMVoice *v, const GMProgramParams *p, double f0, + double sr) { + const double mul = 0.9; + v->harp_lp1 = 0.0; + v->ks_use_big = 1; + v->ks_hard_clip = p->ks_hard; + v->ks_stretch = p->ks_stretch; + v->ks_loop_b = p->ks_loop_b; + v->ks_beta = voice_jitter(v, p->ks_beta, 0.08, mul); + v->ks_pick_amt = p->ks_pick; + v->ks_drive = p->ks_drive; + v->ks_jawari_depth = p->ks_jawari; + v->ks_jawari_thresh = 0.25; + gm_setup_body_resonance(v, p, sr, mul); + memset(v->ks_buf, 0, sizeof(v->ks_buf)); + double string_delay = sr / f0; + if (string_delay > (double)(GM_KS_BIG_N - 2)) string_delay = (double)(GM_KS_BIG_N - 2); + if (string_delay < 2.0) string_delay = 2.0; + int n = (int)string_delay; + double smooth = p->ks_exc_smooth; if (smooth < 0.0) smooth = 0.0; if (smooth > 1.0) smooth = 1.0; + double last = 0.0; + for (int i = 0; i < n; i++) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + double filt = (1.0 - smooth) * white + smooth * last; + last = filt; + v->ks_buf[i] = (float)filt; + } + int beta_tap = (int)(v->ks_beta * (double)n + 0.5); + if (beta_tap >= 1 && beta_tap < n) { + for (int i = n - 1; i >= beta_tap; i--) { + v->ks_buf[i] -= (float)(v->ks_pick_amt * (double)v->ks_buf[i - beta_tap]); + } + } + v->ks_w = n; + if (p->ks_attack_amp > 0.0) { + double atau = p->ks_attack_ms * 0.001; if (atau < 0.0003) atau = 0.0003; + v->atk_env = voice_jitter(v, p->ks_attack_amp, 0.15, mul); + v->atk_dec = exp(-1.0 / (atau * sr)); + double cf = (p->ks_attack_bp > 0.0) ? (f0 * p->ks_attack_bp) : 1200.0; + if (cf > sr * 0.45) cf = sr * 0.45; + if (cf < 80.0) cf = 80.0; + double Q = (p->ks_attack_bp > 0.0) ? 3.0 : 0.707; + double w0 = 2.0 * M_PI * cf / sr; + double al = sin(w0) / (2.0 * Q); + double a0 = 1.0 + al; + if (p->ks_attack_bp > 0.0) { + v->nb0 = (al) / a0; v->nb1 = 0.0; v->nb2 = (-al) / a0; + } else { + double c = (1.0 - cos(w0)); + v->nb0 = (c / 2.0) / a0; v->nb1 = c / a0; v->nb2 = (c / 2.0) / a0; + } + v->na1 = (-2.0 * cos(w0)) / a0; + v->na2 = (1.0 - al) / a0; + v->nx1 = v->nx2 = v->ny1 = v->ny2 = 0.0; + } else { + v->atk_env = 0.0; + v->atk_dec = 0.0; + } + if (p->ks_sec_ms > 0.0) { + v->sec_trig = (p->ks_sec_ms * 0.001) * sr; + v->sec_amp = p->ks_sec_amp; + } else { + v->sec_trig = -1.0; + v->sec_amp = 0.0; + } +} + +// Subtractive note-on. +static void gm_synthbass_init(GMVoice *v, const GMProgramParams *p, double f0, + double sr) { + const double mul = 0.7; + double fc = voice_detune(v, f0, 6.0, mul); + v->sb_o1_phase = 0.0; v->sb_o1_inc = fc / sr; + if (p->sb_o2_mix > 0.0) { + double f2 = fc * cents_to_ratio(p->sb_o2_cents); + v->sb_o2_phase = voice_rand_phase(v); + v->sb_o2_inc = f2 / sr; + v->sb_o2_square = p->sb_o2_sq; + v->sb_o2_mix = p->sb_o2_mix; + } else { + v->sb_o2_inc = 0.0; v->sb_o2_mix = 0.0; v->sb_o2_square = 0; + v->sb_o2_phase = 0.0; + } + v->sb_sub_phase = voice_rand_phase(v); + v->sb_sub_inc = (fc * 0.5) / sr; + v->sb_sub_mix = p->sb_sub; + if (p->sb_fm0 > 0.0) { + v->sb_fm_index = voice_jitter(v, p->sb_fm0, 0.06, mul); + double fdec = p->sb_fm_ms * 0.001; if (fdec < 0.001) fdec = 0.001; + v->sb_fm_dec = exp(-1.0 / (fdec * sr)); + } else { + v->sb_fm_index = 0.0; v->sb_fm_dec = 1.0; + } + v->sb_lp1 = v->sb_lp2 = 0.0; + v->sb_bp1 = v->sb_bp2 = 0.0; + v->sb_cut = p->sb_cut0; + v->sb_cut_target = p->sb_cut1; + double cms = p->sb_cut_ms * 0.001; if (cms < 0.0005) cms = 0.0005; + v->sb_cut_dec = exp(-1.0 / (cms * sr)); + v->sb_res = voice_jitter(v, p->sb_res, 0.05, mul); + v->sb_pitch_mult = p->sb_psweep > 0.0 ? p->sb_psweep : 1.0; + double pms = p->sb_psweep_ms * 0.001; if (pms < 0.0005) pms = 0.0005; + v->sb_pitch_dec = (p->sb_psweep > 1.0) ? exp(-1.0 / (pms * sr)) : 1.0; + v->sb_sustain = p->sb_sustained; + if (p->sb_drone_mix > 0.0) { + v->sb_drone_phase = voice_rand_phase(v); + v->sb_drone_inc = (fc * 0.5) / sr; + v->sb_drone_mix = p->sb_drone_mix; + } else { + v->sb_drone_inc = 0.0; v->sb_drone_mix = 0.0; v->sb_drone_phase = 0.0; + } + v->sb_breath_lp = 0.0; + v->sb_breath_amt = p->sb_breath; + if (p->sb_vib_hz > 0.0) { + v->sb_vib_phase = voice_rand_phase(v); + double vhz = voice_jitter(v, p->sb_vib_hz, 0.10, mul); + v->sb_vib_inc = vhz / sr; + v->sb_vib_depth = p->sb_vib_depth; + } else { + v->sb_vib_inc = 0.0; v->sb_vib_depth = 0.0; v->sb_vib_phase = 0.0; + } +} + +// Forward declare batch-2 dispatch (defined after gm_voice_init). +static int gm_voice_init_batch2(GMVoice *v, int program, double freq, + double sample_rate); + +int gm_program_implemented(int program) { + if (program < 0) return 0; + if (program < GM_PIANO_PROGRAM_COUNT) return 1; // 0-7 + if (program >= 8 && program <= 15) return 1; // Chromatic Perc + if (program >= 24 && program <= 31) return 1; // Guitar + if (program >= 32 && program <= 39) return 1; // Bass + if (program >= 104 && program <= 111) return 1; // Ethnic + if (program >= 112 && program <= 119) return 1; // Percussive + return 0; +} + +int gm_voice_init(GMVoice *v, int program, double freq, double sample_rate, + uint32_t seed) { + gm_synth_init(); + if (!v) return -1; + // Clear all per-voice state so a recycled GMVoice never carries stale fields. + memset(v, 0, sizeof(*v)); + v->rng_seed = seed; + v->engine = GM_ENGINE_NONE; + if (program < 0) return -1; + // Programs 8+ route to the batch-2 families. + if (program >= GM_PIANO_PROGRAM_COUNT) return gm_voice_init_batch2(v, program, freq, sample_rate); + const GMProgramParams *p = &gm_piano_programs[program]; + double sr = sample_rate > 0.0 ? sample_rate : GM_FALLBACK_SR; + double f0 = freq < 20.0 ? 20.0 : freq; + v->program = program; + v->engine = p->engine; + + if (p->engine == GM_ENGINE_GMPIANO) { + const double mul = 0.8; + int N = p->partials; + if (N > GM_MAX_PARTIALS) N = GM_MAX_PARTIALS; + if (N < 1) N = 1; + v->p_count = N; + v->gm_dual = (p->dual_cents > 0.0) ? 1 : 0; + v->gm_drive = p->drive; + double norm = 1.0 / sqrt((double)N); + double sum_check = 0.0; + for (int k = 0; k < N; k++) { + int n = k + 1; + double fn = (double)n * f0 * sqrt(1.0 + p->B * (double)(n * n)); + fn = voice_detune(v, fn, 6.0, mul); + if (fn > sr * 0.45) fn = sr * 0.45; + v->p_finc[k] = fn / sr; + double a = norm / (double)n; + if (k >= (int)p->tilt_from) a *= p->partial_tilt; + a = voice_jitter(v, a, 0.15, mul); + v->p_amp[k] = a; + sum_check += a; + v->p_phase[k] = voice_rand_phase(v); + double tau = p->tau0 / (1.0 + 0.6 * (double)(n - 1)); + tau = voice_jitter(v, tau, 0.15, mul); + if (tau < 0.02) tau = 0.02; + v->p_dec_mult[k] = exp(-1.0 / (tau * sr)); + if (v->gm_dual) { + double f2 = fn * cents_to_ratio(p->dual_cents); + if (f2 > sr * 0.45) f2 = sr * 0.45; + v->p2_finc[k] = f2 / sr; + v->p2_phase[k] = voice_rand_phase(v); + } + } + (void)sum_check; + double htau = (p->hammer_ms * 0.001); + if (htau < 0.0005) htau = 0.0005; + v->gm_hammer_amp = voice_jitter(v, p->hammer_amp, 0.15, mul); + v->gm_hammer_env = 1.0; + v->gm_hammer_dec = exp(-1.0 / (htau * sr)); + v->gm_hammer_lp = 0.0; + } else if (p->engine == GM_ENGINE_EPIANO) { + const double mul = 0.7; + double fc = voice_detune(v, f0, 6.0, mul); + double fm = voice_detune(v, f0 * p->fm_ratio, 2.0, mul); + double ft = f0 * p->fm_tine_ratio; + if (fc > sr * 0.45) fc = sr * 0.45; + v->fm_cphase = 0.0; v->fm_cinc = fc / sr; + v->fm_mphase = 0.0; v->fm_minc = fm / sr; + v->fm_tphase = 0.0; v->fm_tinc = ft / sr; + v->fm_index = voice_jitter(v, p->fm_index0, 0.06, mul); + v->fm_tindex = voice_jitter(v, p->fm_tine_index0, 0.06, mul); + double idec = p->fm_index_ms * 0.001; if (idec < 0.001) idec = 0.001; + double tdec = p->fm_tine_ms * 0.001; if (tdec < 0.0005) tdec = 0.0005; + v->fm_index_dec = exp(-1.0 / (idec * sr)); + v->fm_tindex_dec = exp(-1.0 / (tdec * sr)); + v->fm_pickup_bias = p->fm_pickup; + v->fm_hp_x1 = 0.0; v->fm_hp_y1 = 0.0; + } else if (p->engine == GM_ENGINE_PLUCK) { + const double mul = 0.9; + v->harp_lp1 = 0.0; + v->ks_use_big = 0; + v->ks_stretch = p->ks_stretch; + v->ks_loop_b = p->ks_loop_b; + v->ks_beta = voice_jitter(v, p->ks_beta, 0.08, mul); + v->ks_pick_amt = p->ks_pick; + v->ks_drive = p->ks_drive; + memset(v->bore_buf, 0, sizeof(v->bore_buf)); + double string_delay = sr / f0; + const int STRING_N = GM_BORE_N; + if (string_delay > (double)(STRING_N - 2)) string_delay = (double)(STRING_N - 2); + if (string_delay < 2.0) string_delay = 2.0; + int n = (int)string_delay; + double last = 0.0; + for (int i = 0; i < n; i++) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + double filt = 0.75 * white + 0.25 * last; + last = white; + v->bore_buf[i] = (float)filt; + } + v->bore_w = n; + } + return 0; +} + +static int gm_voice_init_batch2(GMVoice *v, int program, double freq, + double sample_rate) { + double sr = sample_rate > 0.0 ? sample_rate : GM_FALLBACK_SR; + double f0 = freq < 20.0 ? 20.0 : freq; + v->program = program; + + // ── Chromatic Percussion (GM 9-15 → modal; GM 16 Dulcimer → KS) ── + if (program >= GM_CHROMPERC_FIRST && + program < GM_CHROMPERC_FIRST + GM_CHROMPERC_COUNT) { + v->engine = GM_ENGINE_MODAL; + gm_modal_init(v, &gm_chromperc_programs[program - GM_CHROMPERC_FIRST], f0, sr); + return 0; + } + if (program == 15) { + GMProgramParams dul = { + .engine = GM_ENGINE_PLUCK, .ks_stretch = 0.9990, .ks_loop_b = 0.14, + .ks_beta = 0.18, .ks_pick = 0.85, .ks_drive = 0.0, + .ks_big = 1, .ks_exc_smooth = 0.25, + .ks_attack_amp = 0.10, .ks_attack_ms = 2.0 + }; + v->engine = GM_ENGINE_PLUCK; + gm_ks_big_init(v, &dul, f0, sr); + return 0; + } + + // ── Guitar (GM 25-32 → extended KS) ── + if (program >= GM_GUITAR_FIRST && + program < GM_GUITAR_FIRST + GM_GUITAR_COUNT) { + v->engine = GM_ENGINE_PLUCK; + gm_ks_big_init(v, &gm_guitar_programs[program - GM_GUITAR_FIRST], f0, sr); + return 0; + } + + // ── Bass (GM 33-38 → KS, GM 39-40 → subtractive) ── + if (program >= GM_BASS_FIRST && + program < GM_BASS_FIRST + GM_BASS_PLUCK_COUNT) { + v->engine = GM_ENGINE_PLUCK; + gm_ks_big_init(v, &gm_bass_programs[program - GM_BASS_FIRST], f0, sr); + return 0; + } + + // ── Ethnic plucked (GM 105-108) + Kalimba (109 → modal) ── + if (program >= GM_ETHNIC_FIRST && program <= GM_ETHNIC_FIRST + 3) { + v->engine = GM_ENGINE_PLUCK; + gm_ks_big_init(v, &gm_ethnic_pluck_programs[program - GM_ETHNIC_FIRST], f0, sr); + return 0; + } + if (program == 108) { // GM 109 — Kalimba (modal tine) + v->engine = GM_ENGINE_MODAL; + gm_modal_init(v, &gm_kalimba_program, f0, sr); + return 0; + } + + // ── Percussive (GM 113-119 → modal/membrane) ── + if (program >= GM_PERC_FIRST && + program < GM_PERC_FIRST + GM_PERC_COUNT) { + v->engine = GM_ENGINE_MODAL; + gm_modal_init(v, &gm_perc_programs[program - GM_PERC_FIRST], f0, sr); + (void)f0; + return 0; + } + + // ── Subtractive: Synth Bass 1/2 (39-40) + reed approximations (110-112) ── + for (int i = 0; i < GM_SYNTHBASS_ROWS; i++) { + if (gm_synthbass_programs[i].program == program) { + v->engine = GM_ENGINE_SYNTHBASS; + gm_synthbass_init(v, &gm_synthbass_programs[i].p, f0, sr); + return 0; + } + } + + v->engine = GM_ENGINE_NONE; + return -1; // not implemented in this batch +} + +// ============================================================ +// Generators (operate on GMVoice; env + frequency supplied by host) +// ============================================================ + +static inline double generate_gmpiano_sample(GMVoice *v, double sample_rate, + double env) { + (void)sample_rate; + double s = 0.0; + int N = v->p_count; + for (int k = 0; k < N; k++) { + double a = v->p_amp[k]; + s += a * wt_sin(v->p_phase[k]); + v->p_phase[k] += v->p_finc[k]; + if (v->p_phase[k] >= 1.0) v->p_phase[k] -= 1.0; + if (v->gm_dual) { + s += a * wt_sin(v->p2_phase[k]); + v->p2_phase[k] += v->p2_finc[k]; + if (v->p2_phase[k] >= 1.0) v->p2_phase[k] -= 1.0; + } + v->p_amp[k] *= v->p_dec_mult[k]; + } + if (v->gm_dual) s *= 0.5; + if (v->gm_hammer_env > 0.0001) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + v->gm_hammer_lp = 0.2 * white + 0.8 * v->gm_hammer_lp; + s += v->gm_hammer_amp * v->gm_hammer_env * v->gm_hammer_lp; + v->gm_hammer_env *= v->gm_hammer_dec; + } + if (v->gm_drive > 0.0) { + double pre = 1.0 + 4.0 * v->gm_drive; + s = tanh(pre * s) * (1.0 / pre) * (1.0 + v->gm_drive); + } + return s * env; +} + +static inline double generate_epiano_sample(GMVoice *v, double sample_rate, + double env) { + (void)sample_rate; + double bodymod = wt_sin(v->fm_mphase) * v->fm_index; + double tinemod = wt_sin(v->fm_tphase) * v->fm_tindex; + double car = wt_sin(v->fm_cphase + bodymod + tinemod); + v->fm_cphase += v->fm_cinc; if (v->fm_cphase >= 1.0) v->fm_cphase -= 1.0; + v->fm_mphase += v->fm_minc; if (v->fm_mphase >= 1.0) v->fm_mphase -= 1.0; + v->fm_tphase += v->fm_tinc; if (v->fm_tphase >= 1.0) v->fm_tphase -= 1.0; + v->fm_index *= v->fm_index_dec; + v->fm_tindex *= v->fm_tindex_dec; + double x = car; + if (v->fm_pickup_bias > 0.0) { + double biased = tanh(x + v->fm_pickup_bias); + double y = biased - v->fm_hp_x1 + 0.999 * v->fm_hp_y1; + v->fm_hp_x1 = biased; + v->fm_hp_y1 = y; + x = y; + } + return x * env; +} + +static inline double generate_pluck_sample(GMVoice *v, double sample_rate, + double env, double frequency) { + float *buf; int N; int *wptr; + double fmin; + if (v->ks_use_big) { + buf = v->ks_buf; N = GM_KS_BIG_N; wptr = &v->ks_w; fmin = 25.0; + } else { + buf = v->bore_buf; N = GM_BORE_N; wptr = &v->bore_w; fmin = 50.0; + } + double freq = clampd(frequency, fmin, sample_rate * 0.20); + double string_delay = sample_rate / freq; + if (string_delay > (double)(N - 2)) string_delay = (double)(N - 2); + if (string_delay < 2.0) string_delay = 2.0; + + double delayed = gm_frac_read(buf, N, *wptr, string_delay); + double tap_delay = v->ks_beta * string_delay; + if (tap_delay < 1.0) tap_delay = 1.0; + double picked = delayed - v->ks_pick_amt * + gm_frac_read(buf, N, *wptr, tap_delay); + double damp = (1.0 - v->ks_loop_b) * picked + v->ks_loop_b * v->harp_lp1; + v->harp_lp1 = damp; + double y = v->ks_stretch * damp; + if (v->ks_jawari_depth > 0.0) { + double a = fabs(y); + if (a > v->ks_jawari_thresh) { + double over = (a - v->ks_jawari_thresh); + double buzz = v->ks_jawari_depth * tanh(6.0 * over) * (y < 0.0 ? -1.0 : 1.0); + y += buzz; + } + } + buf[*wptr] = (float)y; + *wptr = (*wptr + 1) % N; + + double s = y; + if (v->atk_env > 0.0001) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + double nf = v->nb0 * white + v->nb1 * v->nx1 + v->nb2 * v->nx2 + - v->na1 * v->ny1 - v->na2 * v->ny2; + v->nx2 = v->nx1; v->nx1 = white; + v->ny2 = v->ny1; v->ny1 = nf; + s += v->atk_env * nf; + v->atk_env *= v->atk_dec; + } + if (v->sec_trig > 0.0) { + v->sec_trig -= 1.0; + if (v->sec_trig <= 0.0) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + buf[*wptr] += (float)(v->sec_amp * white); + v->sec_trig = -1.0; + } + } + + double out = s; + if (v->ks_drive > 0.0) { + double pre = 1.0 + (v->ks_hard_clip ? 9.0 : 4.0) * v->ks_drive; + out = tanh(pre * s) * (1.0 / pre) * (1.0 + v->ks_drive); + } + for (int i = 0; i < v->ks_body_n; i++) { + double by = v->ks_body_g[i] * out + - v->ks_body_a1[i] * v->ks_body_y1[i] + - v->ks_body_a2[i] * v->ks_body_y2[i]; + v->ks_body_y2[i] = v->ks_body_y1[i]; + v->ks_body_y1[i] = by; + out += by; + } + return 2.5 * out * env; +} + +static inline double generate_modal_sample(GMVoice *v, double sample_rate, + double env) { + (void)sample_rate; + double s = 0.0; + int N = v->p_count; + double fund = 0.0; + for (int k = 0; k < N; k++) { + double a = v->p_amp[k]; + double partial = a * wt_sin(v->p_phase[k]); + s += partial; + if (k == 0) fund = partial; + v->p_phase[k] += v->p_finc[k]; + if (v->p_phase[k] >= 1.0) v->p_phase[k] -= 1.0; + v->p_amp[k] *= v->p_dec_mult[k]; + } + if (v->gm_modal_bloom > 0.0) { + v->gm_modal_fund = 0.99 * v->gm_modal_fund + 0.01 * (fund * fund); + s += v->gm_modal_bloom * v->gm_modal_fund * 4.0; + } + if (v->gm_hammer_env > 0.0001) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + v->gm_hammer_lp = 0.3 * white + 0.7 * v->gm_hammer_lp; + s += v->gm_hammer_amp * v->gm_hammer_env * v->gm_hammer_lp; + v->gm_hammer_env *= v->gm_hammer_dec; + } + if (v->gm_trem_inc > 0.0) { + double trem = 1.0 - v->gm_trem_depth * 0.5 * (1.0 - wt_sin(v->gm_trem_phase)); + v->gm_trem_phase += v->gm_trem_inc; + if (v->gm_trem_phase >= 1.0) v->gm_trem_phase -= 1.0; + s *= trem; + } + return s * env; +} + +static inline double generate_synthbass_sample(GMVoice *v, double sample_rate, + double env) { + double vib = 1.0; + if (v->sb_vib_inc > 0.0) { + vib = 1.0 + v->sb_vib_depth * wt_sin(v->sb_vib_phase); + v->sb_vib_phase += v->sb_vib_inc; + if (v->sb_vib_phase >= 1.0) v->sb_vib_phase -= 1.0; + } + double pmult = v->sb_pitch_mult * vib; + + double fmod = 0.0; + int has_fm = (v->sb_fm_dec < 1.0 && v->sb_fm_index > 0.00001); + if (has_fm) { + fmod = v->sb_fm_index * wt_sin(v->sb_o1_phase); + v->sb_fm_index *= v->sb_fm_dec; + } + + double o1 = has_fm ? wt_sin(v->sb_o1_phase + fmod) : (2.0 * v->sb_o1_phase - 1.0); + v->sb_o1_phase += v->sb_o1_inc * pmult; + if (v->sb_o1_phase >= 1.0) v->sb_o1_phase -= 1.0; + + double sig = o1; + if (v->sb_o2_mix > 0.0) { + double o2; + if (v->sb_o2_square) { + o2 = v->sb_o2_phase < 0.5 ? 1.0 : -1.0; + } else { + o2 = 2.0 * v->sb_o2_phase - 1.0; + } + sig += v->sb_o2_mix * o2; + v->sb_o2_phase += v->sb_o2_inc * pmult; + if (v->sb_o2_phase >= 1.0) v->sb_o2_phase -= 1.0; + } + if (v->sb_sub_mix > 0.0) { + sig += v->sb_sub_mix * wt_sin(v->sb_sub_phase); + v->sb_sub_phase += v->sb_sub_inc * pmult; + if (v->sb_sub_phase >= 1.0) v->sb_sub_phase -= 1.0; + } + if (v->sb_drone_mix > 0.0) { + sig += v->sb_drone_mix * (2.0 * v->sb_drone_phase - 1.0); + v->sb_drone_phase += v->sb_drone_inc; + if (v->sb_drone_phase >= 1.0) v->sb_drone_phase -= 1.0; + } + if (v->sb_breath_amt > 0.0) { + double white = ((double)xorshift32(&v->rng_seed) / (double)UINT32_MAX) * 2.0 - 1.0; + v->sb_breath_lp = 0.15 * white + 0.85 * v->sb_breath_lp; + sig += v->sb_breath_amt * v->sb_breath_lp; + } + sig *= 0.5; + + if (v->sb_pitch_dec < 1.0) { + v->sb_pitch_mult = 1.0 + (v->sb_pitch_mult - 1.0) * v->sb_pitch_dec; + } + + v->sb_cut = v->sb_cut_target + (v->sb_cut - v->sb_cut_target) * v->sb_cut_dec; + double fc = clampd(v->sb_cut, 30.0, sample_rate * 0.45); + double g = 1.0 - exp(-2.0 * M_PI * fc / sample_rate); + double in = sig - v->sb_res * 4.0 * (v->sb_lp2 - v->sb_bp2); + v->sb_lp1 += g * (in - v->sb_lp1); + v->sb_lp2 += g * (v->sb_lp1 - v->sb_lp2); + v->sb_bp2 = v->sb_lp1; + double out = v->sb_lp2; + return clampd(out, -1.5, 1.5) * env; +} + +double gm_voice_render(GMVoice *v, double sample_rate, double env, + double frequency) { + switch (v->engine) { + case GM_ENGINE_GMPIANO: return generate_gmpiano_sample(v, sample_rate, env); + case GM_ENGINE_EPIANO: return generate_epiano_sample(v, sample_rate, env); + case GM_ENGINE_PLUCK: return generate_pluck_sample(v, sample_rate, env, frequency); + case GM_ENGINE_MODAL: return generate_modal_sample(v, sample_rate, env); + case GM_ENGINE_SYNTHBASS: return generate_synthbass_sample(v, sample_rate, env); + default: return 0.0; + } +} diff --git a/fedac/native/src/gm_synth.h b/fedac/native/src/gm_synth.h new file mode 100644 index 000000000..3b3f83166 --- /dev/null +++ b/fedac/native/src/gm_synth.h @@ -0,0 +1,178 @@ +// gm_synth.h — Standalone, dependency-free General-MIDI synthesis module. +// +// Extracted from audio.c so the algorithmic GM voice set (modal-additive piano, +// FM electric piano, extended Karplus-Strong plucked strings, modal-bank +// chromatic/percussive, subtractive synth bass / reed approximations) can be +// built and tested WITHOUT the ALSA/engine dependencies of audio.c — and later +// shared into the Menu Band macOS app. +// +// This header pulls in ONLY standard C (stdint, math, string). It does NOT +// include audio.h, alsa, or any engine header. A GMVoice owns ALL per-voice GM +// state (partial arrays, FM operators, KS string state, modal bank, subtractive +// filter state, its own KS delay buffers, its own noise biquad + attack/secondary +// burst envelopes, and its own xorshift RNG seed). The host engine supplies only +// the amplitude envelope and the (smoothed) current frequency at render time. +// +// DSP math is byte-for-byte the same as the original audio.c implementation — +// this is a MOVE + DECOUPLE, not a redesign. No per-sample sinf(): sustained +// tones read the shared wt_sin wavetable (phase-increment rule). +// +// Public API: +// void gm_synth_init(void); // build wt_sin once (idempotent) +// void gm_set_organic(double amt); // global organic knob (0..1) +// int gm_program_implemented(int program); // 1 if program 0..127 has a voice +// int gm_voice_init(GMVoice*, int program, double freq, double sr, uint32_t seed); +// double gm_voice_render(GMVoice*, double sr, double env, double frequency); + +#ifndef GM_SYNTH_H +#define GM_SYNTH_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// ── Compile-time sizes (must match the original ACVoice GM scratch) ── +#define GM_MAX_PARTIALS 12 // modal-additive partial cap +#define GM_KS_BIG_N 8192 // long KS delay line (guitar/bass — low notes) +#define GM_BORE_N 2048 // short KS delay line (harpsichord/clavi) + +// gm_synth's own engine enum. The host engine no longer needs to know which +// WaveType a program maps to — gm_voice_init() stores the chosen engine here and +// gm_voice_render() dispatches on it internally. +typedef enum { + GM_ENGINE_NONE = 0, + GM_ENGINE_GMPIANO, // modal additive + inharmonicity (acoustic pianos) + GM_ENGINE_EPIANO, // 2-op FM tine/reed (electric pianos) + GM_ENGINE_PLUCK, // extended Karplus-Strong (harpsichord/clavi/guitar/bass/ethnic) + GM_ENGINE_MODAL, // modal bank (chromatic perc / kalimba / percussive) + GM_ENGINE_SYNTHBASS // subtractive (synth bass / reed approximations) +} GMEngine; + +// Per-voice GM synthesis state. Self-contained: holds every field the GM voices +// touched in ACVoice plus its own copies of the buffers/biquad/burst envelopes +// that previously aliased shared ACVoice scratch (whistle_bore_buf, the noise +// biquad, gun_click_*, gun_secondary_*). The engine embeds one of these per voice. +typedef struct { + GMEngine engine; // which generator renders this voice + int program; // GM program (0-based) — debug / routing + + // Own randomness — xorshift32 stream seeded per-trigger from the host seed. + uint32_t rng_seed; + + // -- Modal additive partials (gmpiano + modal bank) -- + int p_count; + double p_phase[GM_MAX_PARTIALS]; + double p_amp[GM_MAX_PARTIALS]; + double p_finc[GM_MAX_PARTIALS]; + double p_dec_mult[GM_MAX_PARTIALS]; + int gm_dual; // honky-tonk: 2nd detuned string set + double p2_phase[GM_MAX_PARTIALS]; + double p2_finc[GM_MAX_PARTIALS]; + double gm_drive; // per-voice tanh drive (electric grand) + double gm_hammer_env; // hammer/strike noise-burst envelope + double gm_hammer_dec; + double gm_hammer_amp; + double gm_hammer_lp; // 1-pole LPF state for hammer noise + + // -- FM tine/reed operators (epiano) -- + double fm_cphase, fm_cinc; + double fm_mphase, fm_minc; + double fm_index, fm_index_dec; + double fm_tphase, fm_tinc; + double fm_tindex, fm_tindex_dec; + double fm_pickup_bias; + double fm_hp_x1, fm_hp_y1; // DC blocker after pickup nonlinearity + + // -- Extended Karplus-Strong string state (pluck) -- + double harp_lp1; // loop-LPF state + double ks_stretch; + double ks_loop_b; + double ks_beta; + double ks_pick_amt; + double ks_drive; + float bore_buf[GM_BORE_N]; // short delay line (harpsichord/clavi) + int bore_w; + float ks_buf[GM_KS_BIG_N]; // long delay line (guitar/bass/ethnic) + int ks_w; + int ks_use_big; // 1 = render from ks_buf + int ks_hard_clip; // 1 = hard waveshaper (distortion guitar) + double ks_jawari_depth; // buzzing-bridge nonlinearity depth + double ks_jawari_thresh; + double ks_body_a1[3], ks_body_a2[3]; // body-resonance biquad coeffs + double ks_body_g[3]; + double ks_body_y1[3], ks_body_y2[3]; // body-resonance biquad state + int ks_body_n; + + // Attack-noise burst (finger thump / pick click / slap clack) — own biquad + + // envelope (these aliased the engine noise biquad + gun_click_* in audio.c). + double atk_env; // burst amplitude envelope + double atk_dec; // per-sample decay multiplier + double nb0, nb1, nb2, na1, na2; // burst biquad coefficients + double nx1, nx2, ny1, ny2; // burst biquad state + // Secondary excitation (slap snap-back) — own countdown (aliased gun_secondary_*). + double sec_trig; // sample countdown (<=0 = fired/idle) + double sec_amp; + + // -- Modal-bank extras (modal) -- + double gm_trem_phase; + double gm_trem_inc; + double gm_trem_depth; + double gm_modal_bloom; + double gm_modal_fund; + int gm_modal_pitched; + + // -- Subtractive synth bass / reed-as-saw (synthbass) -- + double sb_o1_phase, sb_o1_inc; + double sb_o2_phase, sb_o2_inc; + int sb_o2_square; + double sb_o2_mix; + double sb_sub_phase, sb_sub_inc; + double sb_sub_mix; + double sb_fm_index, sb_fm_dec; + double sb_lp1, sb_lp2; + double sb_bp1, sb_bp2; + double sb_cut, sb_cut_target; + double sb_cut_dec; + double sb_res; + double sb_pitch_mult, sb_pitch_dec; + int sb_sustain; + double sb_drone_phase, sb_drone_inc; + double sb_drone_mix; + double sb_breath_lp; + double sb_breath_amt; + double sb_vib_phase, sb_vib_inc; + double sb_vib_depth; +} GMVoice; + +// Build the wt_sin wavetable. Idempotent; safe to call repeatedly. gm_voice_init +// calls it internally so callers need not, but it is exposed for explicit setup. +void gm_synth_init(void); + +// Global "organic" amount: 0 = bit-identical, 1 = max tasteful spread. Default 0.6. +void gm_set_organic(double amt); + +// 1 if `program` (0-based GM) has a bespoke algorithmic voice in this build. +// Implemented: Piano 0-7, Chromatic Perc 8-15, Guitar 24-31, Bass 32-39, +// Ethnic 104-111, Percussive 112-119. +int gm_program_implemented(int program); + +// Note-on init. Selects the engine, fills per-voice state from the matching +// program row, and applies bounded per-trigger stochasticism drawn from `seed` +// (every voice probabilistic — the same note never renders identically). Returns +// 0 on success, -1 for an unimplemented program (leaves engine = GM_ENGINE_NONE). +int gm_voice_init(GMVoice *v, int program, double freq, double sample_rate, + uint32_t seed); + +// Render one sample. The host engine supplies the amplitude envelope `env` +// (0..1) and the current (smoothed) `frequency` in Hz — gm_synth owns everything +// else. Dispatches on the voice's stored engine. Returns the mono sample. +double gm_voice_render(GMVoice *v, double sample_rate, double env, + double frequency); + +#ifdef __cplusplus +} +#endif + +#endif // GM_SYNTH_H