diff --git a/pop/novelizer/README.md b/pop/novelizer/README.md index 5b9cc4c47..96e2b19fc 100644 --- a/pop/novelizer/README.md +++ b/pop/novelizer/README.md @@ -63,6 +63,7 @@ fairly. Melodies exercise different musical demands: | `lyrical` | sustain, expressivity, phrase shape | | `stab` | transients, fast staccato articulation | | `drone` | long-note evolution, internal motion | +| `beat` | percussion: accents, retriggers, bleed | | `chromatic` | tuning sanity, semitone resolution | ## Workflow (per batch) diff --git a/pop/novelizer/REPORT.md b/pop/novelizer/REPORT.md index 9dff2ec42..d7ce36346 100644 --- a/pop/novelizer/REPORT.md +++ b/pop/novelizer/REPORT.md @@ -1,4 +1,44 @@ -# novelizer — batch 1 report +# novelizer — batch reports + +# batch 2 — percussion (kicks + snares) + +**Rendered + judged**: 2026-07-02 · 6 builders + 6 adversarial judges +(12 agents, ~628k subagent tokens). New harness melody: `beat` — a 2-bar +104 BPM 16th accent map at C1, the percussion battery. **All six kept** +(threshold: novelty ≥ 6, punch ≥ 6, mixability ≥ 5). + +| voice | role | technique (citation) | N | P | M | +|--------------|-------|---------------------------------------------------------|---|---|---| +| memkick | kick | tension-modulated Bessel membrane (Fletcher & Rossing; Bilbao) | 7 | 9 | 8 | +| cavikick | kick | Helmholtz port + Ingard–Ising orifice loss (Thiele/Small) | 8 | 7 | 8 | +| implokick | kick | chaotic-collapse feedback FM (Tomisawa; chaosfm lineage) | 7 | 8 | 8 | +| wiresnare | snare | membrane + wire contact collisions (Rossing; Bilbao) | 8 | 8 | 8 | +| cracklesnare | snare | logistic-map spike train (May 1976; Shaw's faucet) | 8 | 8 | 7 | +| gransnare | snare | stochastic pulsaret cloud (Roads; pulsar lineage) | 7 | 7 | 7 | + +N novelty · P punch · M mixability. Standouts: **memkick**'s pitch drop is +emergent (glide depth/speed/spectrum/decay co-vary with strike force through +one energy variable — accents dive, ghosts barely bend); **cavikick** is the +air side of a drum, not the head (fixed Helmholtz pitch, velocity-squared +port loss = physical compression, flow-gated chuff); **wiresnare**'s and +**cracklesnare**'s tails granulate/sputter instead of hissing (collision +process / chaotic orbit, not filtered noise); **gransnare**'s body is a +statistical condensation of phase-locked grains. + +Judges' improvement threads: memkick low-vel T60 cap + small-speaker +translation (write kick lines at C2+), cavikick knock presence, implokick +optional HF tilt, wiresnare persistent wire bank for true rolls, +cracklesnare top-end air + body register floor, gransnare fold floor +~160-180 Hz. + +**First deployment**: `pop/novelette/` — memkick (downbeats) + cavikick +(shuffle ghosts) + gransnare (backbeat) + cracklesnare in a STRETCHED +adaptation (note length scales every time constant → multi-second chaos +washes at the section doors). + +--- + +# batch 1 report **Rendered + judged**: 2026-07-02 · 6 builder agents + 6 adversarial judges (12 agents, ~617k subagent tokens) · harness `c/novelizer.h`, features via diff --git a/pop/novelizer/c/novelizer.h b/pop/novelizer/c/novelizer.h index db6b312e1..b31d1847e 100644 --- a/pop/novelizer/c/novelizer.h +++ b/pop/novelizer/c/novelizer.h @@ -106,6 +106,20 @@ static NvNote nv_mel_drone[] = { NV_N(45, 8.5, 8.0, 0.80), }; +/* beat — two bars of 16ths at 104bpm (0.144s step), C1 fundamental, + boom-bap accent map; the percussion battery: velocity dynamics, + retrigger behavior, and tail bleed between hits */ +static NvNote nv_mel_beat[] = { + NV_N(24, 0.000, 0.10, 1.00), NV_N(24, 0.433, 0.10, 0.55), + NV_N(24, 0.866, 0.10, 0.80), NV_N(24, 1.154, 0.10, 0.95), + NV_N(24, 1.443, 0.10, 0.50), NV_N(24, 1.732, 0.10, 0.85), + NV_N(24, 2.021, 0.10, 0.60), NV_N(24, 2.165, 0.10, 0.40), + NV_N(24, 2.309, 0.10, 1.00), NV_N(24, 2.742, 0.10, 0.55), + NV_N(24, 3.175, 0.10, 0.80), NV_N(24, 3.463, 0.10, 0.95), + NV_N(24, 3.752, 0.10, 0.50), NV_N(24, 3.896, 0.10, 0.70), + NV_N(24, 4.041, 0.10, 0.85), NV_N(24, 4.185, 0.10, 0.60), +}; + /* chromatic — one octave up from C4, tuning sanity */ static NvNote nv_mel_chromatic[] = { NV_N(60, 0.0, 0.28, 0.75), NV_N(61, 0.3, 0.28, 0.75), @@ -122,6 +136,7 @@ static NvMelody nv_melodies[] = { { "lyrical", nv_mel_lyrical, (int)(sizeof nv_mel_lyrical / sizeof(NvNote)), 0 }, { "stab", nv_mel_stab, (int)(sizeof nv_mel_stab / sizeof(NvNote)), 0 }, { "drone", nv_mel_drone, (int)(sizeof nv_mel_drone / sizeof(NvNote)), 0 }, + { "beat", nv_mel_beat, (int)(sizeof nv_mel_beat / sizeof(NvNote)), 0 }, { "chromatic", nv_mel_chromatic, (int)(sizeof nv_mel_chromatic / sizeof(NvNote)), 0 }, }; enum { NV_MELODY_COUNT = (int)(sizeof nv_melodies / sizeof(NvMelody)) }; diff --git a/pop/novelizer/c/voices/cavikick.c b/pop/novelizer/c/voices/cavikick.c new file mode 100644 index 000000000..1d28bfb19 --- /dev/null +++ b/pop/novelizer/c/voices/cavikick.c @@ -0,0 +1,190 @@ +// cavikick.c — Helmholtz-resonator / bass-reflex-port kick drum. +// +// TECHNIQUE +// The drum "shell" is modeled as a lumped Helmholtz resonator: the slug +// of air in a port (mass) oscillates against the compliance of the air +// in the cavity (spring). A beater hit is an impulsive pressure step on +// the cavity, so per note we integrate +// +// v' = -w0^2 x - ( d_lin + d_nl * |v|/w ) v + F(t) +// x' = v +// +// with symplectic Euler at 48 kHz (frequency-prewarped so the discrete +// rotation lands exactly on the note pitch). The radiated sound is the +// port volume velocity, y = v / w. The |v| * v term is the classic +// NONLINEAR ORIFICE RESISTANCE: jet separation at the port makes losses +// grow with air speed, so loud hits decay faster and rounder than soft +// ones — an amplitude-dependent envelope no ADSR gives you for free. +// Port turbulence is audible as a "chuff": deterministic noise, band- +// limited to ~250 Hz..1.25 kHz, gated by u^2 (u = normalized port +// velocity), so the breath pulses at 2*f with the flow peaks and dies +// twice as fast as the tone. One fast-decaying inharmonic mode at +// 6.27 * f stands in for the first standing-wave mode of the cavity +// (where the lumped approximation ends) and supplies the woody knock. +// The cavity is tuned so the Helmholtz frequency IS the note frequency; +// damping-induced flatting is compensated in w0. +// +// CITATION / LINEAGE +// - H. von Helmholtz, "On the Sensations of Tone" (1863) — cavity +// resonators: air mass in a neck against cavity compliance. +// - U. Ingard & H. Ising, "Acoustic Nonlinearity of an Orifice", +// JASA 42(1), 1967 — orifice resistance grows ~ |u| (velocity-squared +// pressure loss), the d_nl * |v| damping term here. +// - A. N. Thiele (1971) / R. H. Small (1972), JAES — the bass-reflex +// (vented-box) lumped model: port output = Helmholtz mode of the box. +// - Fletcher & Rossing, "The Physics of Musical Instruments", ch. on +// ocarinas/jug resonators — blown/struck Helmholtz oscillators. +// +// WHAT MAKES IT NOVEL HERE +// Every kick already in the AC stable synthesizes the MEMBRANE side: +// hellsine's distorted pitch-swept sine, percussion.mjs's 808/909 +// envelopes, nullnoise's carved noise bursts, gm_synth's GM drums. +// cavikick synthesizes the AIR side — the box, not the head: a struck +// Helmholtz cavity whose pitch is the port resonance, whose dynamics +// come from physical velocity-squared port loss (loud = shorter and +// rounder), and whose attack breath is flow-gated turbulence rather +// than an enveloped noise sample. No pitch sweep, no waveshaper, no +// membrane: hollow, woody, breathy — air punched out of a box. No +// batch-1 lineage (scanner/pulsar/chaosfm/frictus/vosim/twomass all +// untouched). +// +// PARAMETER MAP +// note freq -> Helmholtz frequency f_H (cavity tuning), prewarped + +// damping-compensated so the ring is on pitch +// note vel -> pressure step P = vel^1.35, beater fall time +// 6ms -> 3.5ms (harder beater), chuff level ~ vel^2 +// (via u^2 gate), deeper nonlinear-loss rounding +// d_lin T60 ~ 300 ms small-signal decay (always < 400 ms) +// d_nl ~2.3x extra damping at full swing (Ingard-Ising loss) +// chuff noise * u^2 * exp(-t/70ms), BP 250..1250 Hz, gain 0.55 +// knock 2nd resonator at 6.27*f, T60 55 ms, gain 0.13 (cavity +// standing-wave mode; skipped if > 18 kHz) +// per-note FNV hash -> +/-0.3% cavity detune, chuff seed, beater jitter +// dur ignored: the resonator's own losses are the envelope +// (capped at 0.85 s, 10 ms safety fade, never past nframes) + +#include "../novelizer.h" + +#define NOTE_MAX_SEC 0.85 +#define FADE_SEC 0.010 +#define D_LIN (2.0 * 6.9078 / 0.30) /* T60 = 300 ms small-signal */ +#define D_NL 105.0 /* extra damping per unit |u| */ +#define KNOCK_RATIO 6.27 +#define KNOCK_T60 0.055 +#define KNOCK_GAIN 0.13 +#define CHUFF_GAIN 0.55 +#define CHUFF_TAU 0.070 +#define CHUFF_LP_HZ 1250.0 +#define CHUFF_HP_HZ 250.0 + +/* FNV-1a note hash -> deterministic per-hit variation */ +static uint32_t note_hash(int idx, double freq, double start) { + uint32_t h = 2166136261u; + uint32_t a = (uint32_t)idx, b = (uint32_t)(freq * 64.0), c = (uint32_t)(start * 1000.0); + h = (h ^ a) * 16777619u; + h = (h ^ b) * 16777619u; + h = (h ^ c) * 16777619u; + return h; +} + +static uint32_t xs32(uint32_t *s) { /* xorshift32, deterministic noise */ + uint32_t x = *s; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + return *s = x; +} + +/* symplectic-Euler prewarp: discrete rotation of exactly w rad/s */ +static double prewarp(double w, double dt) { + double a = 0.5 * w * dt; + if (a > 1.5) a = 1.5; /* clamp near Nyquist */ + return 2.0 * sin(a) / dt; +} + +static void render_note(const NvNote *n, int idx, float *out, int nframes) { + const double dt = 1.0 / NV_SR; + int s0 = (int)(n->start * NV_SR); + if (s0 >= nframes) return; + int len = (int)(NOTE_MAX_SEC * NV_SR); + if (s0 + len > nframes) len = nframes - s0; + int fade = (int)(FADE_SEC * NV_SR); + + uint32_t h = note_hash(idx, n->freq, n->start); + uint32_t rng = h | 1u; + double detune = 1.0 + (((h >> 8) & 0xffff) / 65535.0 - 0.5) * 0.006; + double jit = (((h >> 20) & 0xff) / 255.0 - 0.5) * 0.10; /* +/-5% beater */ + + /* cavity tuning: target angular freq, damping-compensated + prewarped */ + double wt = NV_TAU * n->freq * detune; + double w0 = prewarp(sqrt(wt * wt + 0.25 * D_LIN * D_LIN), dt); + double w02 = w0 * w0; + + /* beater pressure step: fast rise, velocity-shortened fall */ + double tau_r = 0.0007; + double tau_f = (0.006 - 0.0025 * n->vel) * (1.0 + jit); + double P = pow(n->vel, 1.35) * wt * wt; /* scale so peak |x| ~ vel^1.35 */ + + /* knock mode: first cavity standing wave beyond the lumped model */ + double fk = n->freq * KNOCK_RATIO; + int knock_on = fk < 18000.0; + double wk = knock_on ? prewarp(NV_TAU * fk, dt) : 0.0; + double wk2 = wk * wk; + double dk = 2.0 * 6.9078 / KNOCK_T60; + + /* chuff filters: one-pole LP x2 + one-pole HP */ + double klp = 1.0 - exp(-NV_TAU * CHUFF_LP_HZ * dt); + double khp = 1.0 - exp(-NV_TAU * CHUFF_HP_HZ * dt); + double lp1 = 0, lp2 = 0, hpt = 0; + + double x = 0, v = 0; /* port air mass: displacement, velocity */ + double xk = 0, vk = 0; /* knock mode */ + + for (int i = 0; i < len; i++) { + double t = i * dt; + + /* beater force */ + double F = P * (1.0 - exp(-t / tau_r)) * exp(-t / tau_f); + + /* Helmholtz mode with Ingard-Ising nonlinear port loss */ + double u = v / w0; /* normalized port velocity (~ swing amplitude) */ + double au = fabs(u); + double d = D_LIN + D_NL * (au > 1.4 ? 1.4 : au); + v *= exp(-d * dt); + v += dt * (-w02 * x + F); + x += dt * v; + + double y = v / w0; /* radiated port volume velocity */ + + /* knock */ + if (knock_on) { + vk *= exp(-dk * dt); + vk += dt * (-wk2 * xk + F); + xk += dt * vk; + y += KNOCK_GAIN * (vk / wk); + } + + /* port turbulence chuff: noise gated by u^2, band-limited */ + double w = ((double)(xs32(&rng) >> 8) / 8388608.0) - 1.0; /* -1..1 */ + lp1 += klp * (w - lp1); + lp2 += klp * (lp1 - lp2); + hpt += khp * (lp2 - hpt); + double band = lp2 - hpt; + double gate = u * u; + if (gate > 1.5) gate = 1.5; + y += CHUFF_GAIN * band * gate * exp(-t / CHUFF_TAU); + + /* safety fade at the hard cap (tail is ~ -80 dB there anyway) */ + if (i >= len - fade) y *= (double)(len - i) / fade; + + out[s0 + i] += (float)(0.9 * y); + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_note(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { return nv_main(argc, argv, "cavikick", render); } diff --git a/pop/novelizer/c/voices/cracklesnare.c b/pop/novelizer/c/voices/cracklesnare.c new file mode 100644 index 000000000..451fff157 --- /dev/null +++ b/pop/novelizer/c/voices/cracklesnare.c @@ -0,0 +1,192 @@ +// cracklesnare.c — chaotic spike-train crackle snare. +// +// TECHNIQUE +// The snare's "noise" is not filtered white noise: it is a CHAOTIC SPIKE +// TRAIN. A logistic map +// +// x' = r · x · (1 − x) +// +// is iterated once per spike; the orbit value x sets BOTH the amplitude +// of the spike and the inter-onset interval to the next one (the +// dripping-faucet reading of the map: chaotic drip timing). r is +// enveloped across the hit — full chaos (r → 4) at the attack, relaxing +// toward the periodic window (r → 3.2, a period-2 orbit) in the tail, so +// every hit begins as fractal broadband sputter and collapses into an +// audible buzzy comb as the spike train turns periodic. Each spike +// excites (a) a very short high bandpass "wire" resonator — the crack — +// and (b) a 3-mode body resonator seated at the note frequency — the +// drum. Spike density decays across the hit like rain moving off a tin +// roof. +// +// CITATION / LINEAGE +// - R. M. May, "Simple mathematical models with very complicated +// dynamics", Nature 261 (1976) — the logistic map, its bifurcation +// cascade and periodic windows (r ≈ 3.2 period-2 vs r → 4 chaos). +// - R. Shaw, "The Dripping Faucet as a Model Chaotic System" (Aerial +// Press, 1984) — chaotic inter-onset intervals of discrete events, +// the model for spike TIMING here. +// - Curtis Roads, Microsound (MIT Press, 2001), ch. on noise particles / +// trainlets — sound built from irregular particle emission rather than +// from a continuous noise source; SuperCollider's Crackle UGen is the +// same family (chaotic-map noise, Voss crackle lineage). +// +// WHAT MAKES IT NOVEL HERE +// hellsine's kick is a distorted sine, nullnoise carves FILTERED +// pink/white/brown/velvet bursts, percussion.mjs and gm_synth are +// conventional 808/909/GM noise-plus-tone snares. None of them emit +// discrete events from a chaotic orbit: cracklesnare's noise floor is a +// countable spike train whose statistics are fractal, so the tail +// sputters and rattles (fireworks / rain-on-tin) instead of hissing, and +// the r-envelope makes each hit literally traverse the bifurcation +// diagram — chaos at the crack, period-2 buzz at the tail. Lineage link +// to batch-1 chaosfm is declared: both play a nonlinear map, but chaosfm +// bends a continuous FM operator while cracklesnare quantizes chaos into +// particle timing (Roads' microsound axis, untouched in batch 1). +// +// PARAMETER MAP +// rate0 1400 + 5200·vel spike rate at the attack (spikes/s) +// rate env ×(0.10 + 0.90·e^(−t/55ms)) density thins into the tail +// r ceiling 3.62 + 0.38·vel velocity buys chaos depth (≤ 4.0) +// r floor 3.20 period-2 window = tail buzz comb +// r env e^(−t/80ms) blend from ceiling down to floor +// spike amp sign(x−xprev) · (0.15 + 0.85·x) · e^(−t/Tc) +// Tc 90 + 70·vel ms crackle amplitude decay +// crack res 2-pole BP, fc = (2.6k + 2.6k·vel)·(±15% hashed), τ 0.7 ms +// body res modes f0·{1.0, 1.593, 2.258}, τ {200, 110, 60} ms, +// amps {1.0, 0.62, 0.40} — membrane-ish inharmonic stack +// strike one full-strength impulse at t=0 (clean transient) +// seeding splitmix64 of (note index, start, freq); no libc rand +// note dur ignored — the envelope owns the decay (one-shot drum) + +#include "../novelizer.h" + +#define NOTE_SEC 1.0 /* render window per hit; tails die well before */ +#define FADE_SEC 0.02 /* safety cosine fade at the window edge */ +#define R_FLOOR 3.2 /* periodic window: tail buzz */ +#define T_RENV 0.080 /* r ceiling → floor time constant */ +#define T_RATE 0.055 /* spike-density decay time constant */ +#define BODY_MODES 3 + +/* ── deterministic hashing (splitmix64) ───────────────────────────── */ +static uint64_t sm64(uint64_t z) { + z += 0x9e3779b97f4a7c15ULL; + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL; + z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL; + return z ^ (z >> 31); +} +static double h01(uint64_t h) { /* uniform in [0,1) */ + return (double)(h >> 11) * (1.0 / 9007199254740992.0); +} + +/* ── 2-pole resonator (impulse-excited ringing mode) ──────────────── */ +typedef struct { + double a1, a2, y1, y2, g; +} Res2; + +static void res_set(Res2 *r, double f, double tau) { + double fmax = 0.45 * NV_SR; + if (f > fmax) f = fmax; + double th = NV_TAU * f / NV_SR; + double R = exp(-1.0 / (tau * NV_SR)); + r->a1 = 2.0 * R * cos(th); + r->a2 = -R * R; + r->y1 = r->y2 = 0.0; + double s = sin(th); /* peak of the ring is ~1/sin(th): pre-compensate */ + r->g = (s < 1e-4) ? 1e-4 : s; +} + +static double res_tick(Res2 *r, double x) { + double y = r->a1 * r->y1 + r->a2 * r->y2 + x * r->g; + r->y2 = r->y1; + r->y1 = y; + return y; +} + +/* ── one hit ──────────────────────────────────────────────────────── */ +static const double body_ratio[BODY_MODES] = { 1.0, 1.593, 2.258 }; +static const double body_tau[BODY_MODES] = { 0.200, 0.110, 0.060 }; +static const double body_amp[BODY_MODES] = { 1.00, 0.62, 0.40 }; + +static void render_note(const NvNote *n, int idx, float *out, int nframes) { + int s0 = (int)(n->start * NV_SR); + if (s0 >= nframes) return; + int len = (int)(NOTE_SEC * NV_SR); + if (s0 + len > nframes) len = nframes - s0; + int fade = (int)(FADE_SEC * NV_SR); + + /* deterministic per-note seed from index + start + freq bits */ + uint64_t fb, sb; + memcpy(&fb, &n->freq, sizeof fb); + memcpy(&sb, &n->start, sizeof sb); + uint64_t seed = sm64(((uint64_t)idx << 32) ^ fb ^ sm64(sb)); + + double vel = n->vel; + double amp = pow(vel, 1.5) * 0.8; + + /* logistic map state: hashed x0 well inside (0,1) */ + double x = 0.15 + 0.70 * h01(sm64(seed ^ 0xA5A5A5A5ULL)); + double r_hi = 3.62 + 0.38 * vel; + if (r_hi > 4.0) r_hi = 4.0; + double rate0 = 1400.0 + 5200.0 * vel; + double t_crackle = 0.090 + 0.070 * vel; + + /* crack: short bright bandpass, hashed ±15% so hits aren't clones */ + Res2 crack; + double fc = (2600.0 + 2600.0 * vel) * (0.85 + 0.30 * h01(sm64(seed ^ 0x51EEULL))); + res_set(&crack, fc, 0.0007); + + /* body: 3 inharmonic modes seated at the note frequency */ + Res2 body[BODY_MODES]; + for (int m = 0; m < BODY_MODES; m++) + res_set(&body[m], n->freq * body_ratio[m], body_tau[m]); + + double countdown = 0.0; /* fire the first spike at i = 0 */ + uint64_t reseed = seed; + + for (int i = 0; i < len; i++) { + double t = (double)i / NV_SR; + double envc = exp(-t / t_crackle); + + double inj = 0.0; + countdown -= 1.0; + if (countdown <= 0.0) { + /* r envelope: chaos at the attack, period-2 window in the tail */ + double r = R_FLOOR + (r_hi - R_FLOOR) * exp(-t / T_RENV); + double xp = x; + x = r * x * (1.0 - x); + if (!(x > 1e-9 && x < 1.0 - 1e-9)) { /* escaped orbit: reinject */ + reseed = sm64(reseed); + x = 0.20 + 0.60 * h01(reseed); + } + double sgn = (x > xp) ? 1.0 : -1.0; + /* spike density thins across the hit (rain moving off the roof) */ + double rate = rate0 * (0.10 + 0.90 * exp(-t / T_RATE)); + countdown += (NV_SR / rate) * (0.35 + 1.30 * x); + inj = sgn * (0.15 + 0.85 * x) * envc; + } + if (i == 0) inj += 1.2; /* the strike: clean full-strength transient */ + + double cr = res_tick(&crack, inj); + double bd = 0.0; + for (int m = 0; m < BODY_MODES; m++) + bd += body_amp[m] * res_tick(&body[m], inj); + + double y = 1.15 * cr + 0.90 * bd + 0.35 * inj; + if (i >= len - fade) /* safety fade at the render-window edge */ + y *= 0.5 * (1.0 + cos(M_PI * (double)(i - (len - fade)) / fade)); + + out[s0 + i] += (float)(y * amp); + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_note(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { + return nv_main(argc, argv, "cracklesnare", render); +} diff --git a/pop/novelizer/c/voices/gransnare.c b/pop/novelizer/c/voices/gransnare.c new file mode 100644 index 000000000..3e96ca771 --- /dev/null +++ b/pop/novelizer/c/voices/gransnare.c @@ -0,0 +1,169 @@ +// gransnare.c — grain-cloud snare: stochastic pulsaret bursts. +// +// TECHNIQUE +// Every hit is a CLOUD of 50..300 pulsarets — short sine bursts under an +// expodec envelope (fast cosine attack, exponential decay, cosine tail) — +// scattered stochastically-uniform in time by a truncated-exponential +// density envelope. Three cloud statistics follow fast envelopes across +// the hit: +// density : dense at onset (the crack), thinning toward the tail +// center : formant center glides log-space from a velocity-scaled +// ceiling (2.2..9 kHz) down to the drum body frequency +// spread : formant scatter is wide at onset (±~1.6 octaves) and +// collapses to nearly zero in the tail +// Tail grains lock their sine phase to a global body oscillator +// (phase = frac(fw * onset)), so as the cloud narrows the overlapping +// pulsarets fuse into a coherent pitched hum at the note frequency — +// the snare "body" is a statistical condensation, not a resonator. +// +// CITATION / LINEAGE +// - Curtis Roads, "Microsound" (MIT Press, 2001), ch. 3 ("Granular +// Synthesis": clouds as statistical objects — density / bandwidth / +// amplitude envelopes over grain populations) and ch. 4 ("Varieties of +// Particle Synthesis": pulsarets, expodec envelopes). +// - Declared batch-1 lineage: the pulsaret particle + expodec window are +// shared with pulsar.c; there they form a PERIODIC train (pitch = train +// rate), here they form an ASYNCHRONOUS cloud (pitch = statistical +// convergence of the formant distribution). +// +// WHAT MAKES IT NOVEL HERE +// The snare is a STATISTICAL object. Velocity does not just scale level: +// it morphs the cloud's probability distribution — grain count (50..300), +// formant ceiling, and hit length — so soft hits are papery sparse taps +// and hard hits are a wall of micro-zaps. No filtered noise anywhere +// (vs nullnoise / percussion.mjs / gm_synth: every sample here is a +// windowed sinusoid); grains are scheduled stochastically-uniform, not +// by a chaotic contact process (vs wiresnare) and not periodically +// (vs pulsar). hellsine's kick is a single distorted sine — no cloud. +// +// PARAMETER MAP (per hit; all randomness = per-note hashed xorshift32) +// fbody note freq octave-folded up to >= 120 Hz (drum body pitch) +// hitdur 0.16 + 0.24*vel seconds (envelopes own the decay; note dur +// is ignored, grains are self-windowed so retrigger is safe) +// N grains 50 + 250*vel^1.6 +// onset burst 6 + 30*vel grains forced into the first 2 ms (the crack) +// onsets truncated-exponential, tau = 0.25*hitdur +// brightness g(t) = exp(-t / (0.030 + 0.020*vel)) +// center fc = fbody * (fmax/fbody)^g, fmax = 2200 + 6800*vel^1.2 +// spread ± (1.6*g + 0.06) octaves, triangular +// body prob (1 - g)^1.2 — tail grains lock to fbody (30% at 1.58*fbody, +// the second membrane-ish partial), phase-coherent +// grain len cloud: 6..10 cycles of fw, clamped 1.5..8 ms (2..6 ms in +// the crack band); body: 3..4.5 cycles, clamped 4..30 ms +// grain amp exp(-onset / (0.35*hitdur)) * jitter(0.75..1.25); body x1.4 +// hit amp vel^1.35 +// +// Self-contained C99, libm only, deterministic (no libc rand). +// cc -O2 -std=c99 -Wall -o build/gransnare voices/gransnare.c -lm + +#include "../novelizer.h" + +/* ── per-note deterministic rng ───────────────────────────────────── */ +static inline uint32_t gs_hash(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352du; + x ^= x >> 15; x *= 0x846ca68bu; + x ^= x >> 16; return x; +} +typedef struct { uint32_t s; } GsRng; +static inline uint32_t gs_next(GsRng *r) { + uint32_t x = r->s; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; + r->s = x; return x; +} +static inline double gs_frand(GsRng *r) { /* uniform 0..1 */ + return (double)(gs_next(r) >> 8) * (1.0 / 16777216.0); +} +static inline double gs_tri(GsRng *r) { /* triangular -1..1 */ + return gs_frand(r) + gs_frand(r) - 1.0; +} + +/* ── pulsaret expodec window (batch-1 pulsar lineage, declared) ───── */ +static inline double gs_win(double x) { + const double att = 0.12, tail = 0.10, k = 5.0; + if (x <= 0.0 || x >= 1.0) return 0.0; + double a = (x < att) ? 0.5 - 0.5 * cos(M_PI * x / att) + : exp(-k * (x - att) / (1.0 - att)); + if (x > 1.0 - tail) a *= 0.5 + 0.5 * cos(M_PI * (x - (1.0 - tail)) / tail); + return a; +} + +static void render_hit(const NvNote *n, int idx, float *out, int nframes) { + const double vel = n->vel; + double fbody = n->freq; + while (fbody < 120.0) fbody *= 2.0; + + GsRng rng; + rng.s = gs_hash((uint32_t)idx * 0x9E3779B9u ^ + (uint32_t)(n->start * NV_SR) ^ + gs_hash((uint32_t)(n->freq * 100.0))); + if (!rng.s) rng.s = 0xACu; + + const double hitdur = 0.16 + 0.24 * vel; + const int ngrains = 50 + (int)(250.0 * pow(vel, 1.6)); + const int nburst = 6 + (int)(30.0 * vel); + const double tau = 0.25 * hitdur; /* onset density e-fold */ + const double bright_tau = 0.030 + 0.020 * vel; + const double fmax = 2200.0 + 6800.0 * pow(vel, 1.2); + const double amp_tau = 0.35 * hitdur; + const double hit_amp = 0.40 * pow(vel, 1.35); + const double trunc = 1.0 - exp(-hitdur / tau); /* exp-draw truncation */ + const int s0 = (int)(n->start * NV_SR); + + for (int g = 0; g < ngrains; g++) { + /* onset: forced crack burst, then truncated-exponential scatter */ + double onset = (g < nburst) + ? gs_frand(&rng) * 0.002 + : -tau * log(1.0 - gs_frand(&rng) * trunc); + + double br = exp(-onset / bright_tau); /* cloud brightness statistic */ + double fw, phase, cycles, amp; + int is_body = gs_frand(&rng) < pow(1.0 - br, 1.2); + + if (is_body) { /* tail condensation: phase-locked body pulsaret */ + fw = fbody * (gs_frand(&rng) < 0.30 ? 1.58 : 1.0); + fw *= 1.0 + 0.004 * gs_tri(&rng); + phase = fw * onset; + phase -= floor(phase); /* coherent with a global body oscillator */ + cycles = 3.0 + 1.5 * gs_frand(&rng); + amp = 1.4; + } else { /* crack: scattered micro-zap */ + double fc = fbody * pow(fmax / fbody, br); + double oct = gs_tri(&rng) * (1.6 * br + 0.06); + fw = fc * pow(2.0, oct); + if (fw < fbody * 0.8) fw = fbody * 0.8; + if (fw > 16000.0) fw = 16000.0; + phase = gs_frand(&rng); + cycles = 6.0 + 4.0 * gs_frand(&rng); + amp = 1.0; + } + + double glen = cycles / fw; /* seconds */ + double lo = is_body ? 0.004 : 0.0015; + double hi = is_body ? 0.030 : 0.008; + if (glen < lo) glen = lo; + if (glen > hi) glen = hi; + + amp *= exp(-onset / amp_tau) * (0.75 + 0.5 * gs_frand(&rng)) * hit_amp; + + int gs0 = s0 + (int)(onset * NV_SR); + int glen_s = (int)(glen * NV_SR); + if (glen_s < 8) glen_s = 8; + double inc = fw / NV_SR, ph = phase; + for (int i = 0; i < glen_s && gs0 + i < nframes; i++) { + double x = (double)(i + 1) / (double)(glen_s + 1); + out[gs0 + i] += (float)(gs_win(x) * sin(NV_TAU * ph) * amp); + ph += inc; + if (ph >= 1.0) ph -= 1.0; + } + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_hit(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { return nv_main(argc, argv, "gransnare", render); } diff --git a/pop/novelizer/c/voices/implokick.c b/pop/novelizer/c/voices/implokick.c new file mode 100644 index 000000000..b18b4d125 --- /dev/null +++ b/pop/novelizer/c/voices/implokick.c @@ -0,0 +1,139 @@ +// implokick.c — chaotic-collapse feedback-FM kick: a bifurcation played +// backwards as a transient. +// +// TECHNIQUE +// One Tomisawa averaged-feedback FM operator: +// +// y[n] = sin(2π·phase[n] + β · lp(avg(y[n-1], y[n-2]))) +// +// For a melodic voice (batch-1 chaosfm) β blooms slowly INTO chaos and +// back. implokick runs the bifurcation the other way and 100× faster: +// every hit is born deep inside the chaotic regime (β ≈ 2–3, broadband +// roar) and β COLLAPSES exponentially to zero over the kick's first +// ~60–150 ms, while the operator frequency falls along a fast exponential +// from ~2–3.5× the note frequency down to exactly 1×. The result is a +// controlled implosion — noise → tone → sub in one gesture — with the +// final sub phase pitch-locked to the note so chromatic kick lines track. +// The operator is run 2× oversampled (96 kHz internally, averaged-pair +// decimation) because at β ≈ 3 the chaotic sidebands otherwise fold. +// +// CITATION / LINEAGE +// - Norio Tomisawa, "Tone production method for an electronic musical +// instrument", US Patent 4,249,447 (1981) — Yamaha operator +// self-feedback with the two-sample averaging stabilizer. +// - Dan Slater, "Chaotic Sound Synthesis", Computer Music Journal 22(2), +// 1998 — feedback FM driven past its bifurcation point as material. +// - Declared lineage: novelizer batch-1 chaosfm.c (same operator core); +// implokick inverts its slow bloom into a percussive collapse. +// +// WHAT MAKES IT NOVEL HERE +// hellsine's kick is a distorted sine sweep; nullnoise carves filtered +// noise bursts; percussion.mjs and gm_synth do conventional 808/909-ish +// recipes (sine drop + separate noise click). implokick has NO separate +// noise source and NO waveshaper: the attack "click", the tonal knock, +// and the sub tail are one operator crossing its own bifurcation. The +// transient is deterministic chaos, not filtered randomness, so it stays +// phase-coherent with the body — a signature none of the existing kit has. +// +// PARAMETER MAP (velocity = strike force) +// β0 1.7 + 1.3·vel initial chaos depth (up to 3.0) +// τβ 18 + 32·vel ms β collapse time constant (implosion life) +// ratio 2.0 + 1.5·vel initial op-freq multiple of note freq +// τp 9 + 11·vel ms pitch-drop time constant +// τa 45 + 50·vel ms amplitude decay (tail < 400 ms always) +// amp vel^1.6 accent law +// register β0 ÷ (1 + f/1500) pitched lines up high bloom shallower +// hash ±8% β0, ±5% ratio deterministic per-hit variation (SplitMix) +// fb loop avg(y1,y2) → one-pole (k=0.5 @96k): Tomisawa zero + parasite damp +// env 0.8 ms linear attack, exp(-t/τa), 20 ms cosine end-fade +// DC block y = x − x′ + 0.999·y′ (chaotic regime is asymmetric) + +#include "../novelizer.h" + +#define KICK_LEN_SEC 0.38 /* whole voice life incl. fade — decay stays tight */ +#define END_FADE_SEC 0.020 /* cosine fade so retriggered tails never pop */ +#define ATTACK_SEC 0.0008 +#define OS 2 /* 2x oversampled operator */ +#define FB_LP_K 0.5 /* one-pole in the feedback loop (at 96 kHz) */ + +/* SplitMix32-style hash for deterministic per-hit variation. */ +static uint32_t ik_hash(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352dU; + x ^= x >> 15; x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} +static double ik_h01(uint32_t h) { return (double)(h & 0xFFFFFFu) / 16777216.0; } + +static void render_note(const NvNote *n, int idx, float *out, int nframes) { + int s0 = (int)(n->start * NV_SR); + if (s0 >= nframes) return; + int len = (int)(KICK_LEN_SEC * NV_SR); + int fade = (int)(END_FADE_SEC * NV_SR); + int atk = (int)(ATTACK_SEC * NV_SR); + if (atk < 1) atk = 1; + + /* deterministic per-hit jitter */ + uint32_t h = ik_hash((uint32_t)idx * 2654435761u ^ (uint32_t)(n->freq * 97.0)); + double jbeta = 0.92 + 0.16 * ik_h01(h); + double jratio = 0.95 + 0.10 * ik_h01(ik_hash(h)); + + /* velocity → chaos depth, drop depth, and every time constant */ + double vel = n->vel; + double beta0 = (1.7 + 1.3 * vel) * jbeta / (1.0 + n->freq / 1500.0); + double ratio = (2.0 + 1.5 * vel) * jratio; + double tau_b = 0.018 + 0.032 * vel; /* β collapse: ~60–150 ms life */ + double tau_p = 0.009 + 0.011 * vel; /* pitch drop */ + double tau_a = 0.045 + 0.050 * vel; /* amp decay */ + double amp = pow(vel, 1.6); + + /* operator state (96 kHz internal) */ + double phase = 0.0, y1 = 0.0, y2 = 0.0, lp = 0.0; + double dc_x1 = 0.0, dc_y1 = 0.0; + + for (int i = 0; i < len && s0 + i < nframes; i++) { + double t = (double)i / NV_SR; + + /* the collapse: chaos depth and operator frequency both implode */ + double beta = beta0 * exp(-t / tau_b); + double f = n->freq * (1.0 + (ratio - 1.0) * exp(-t / tau_p)); + double inc = f / (OS * NV_SR); + + /* 2x oversampled Tomisawa operator, averaged-pair decimation */ + double s = 0.0; + for (int k = 0; k < OS; k++) { + lp += FB_LP_K * (0.5 * (y1 + y2) - lp); + double y = sin(NV_TAU * phase + beta * lp); + y2 = y1; + y1 = y; + phase += inc; + if (phase >= 1.0) phase -= 1.0; + s += y; + } + s *= 1.0 / OS; + + /* DC blocker — the chaotic regime is asymmetric */ + double o = s - dc_x1 + 0.999 * dc_y1; + dc_x1 = s; + dc_y1 = o; + + /* envelope: instant-feeling attack, exp body, cosine end-fade */ + double env = (i < atk) ? (double)i / atk : exp(-(t - ATTACK_SEC) / tau_a); + if (i > len - fade) { + double r = (double)(i - (len - fade)) / fade; + env *= 0.5 * (1.0 + cos(M_PI * r)); + } + + out[s0 + i] += (float)(o * env * amp * 0.85); + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_note(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { return nv_main(argc, argv, "implokick", render); } diff --git a/pop/novelizer/c/voices/memkick.c b/pop/novelizer/c/voices/memkick.c new file mode 100644 index 000000000..f06ee202b --- /dev/null +++ b/pop/novelizer/c/voices/memkick.c @@ -0,0 +1,169 @@ +// memkick.c — nonlinear circular-membrane modal kick (tension-modulation glide). +// +// TECHNIQUE +// The drumhead is a bank of circular-membrane modes at the ideal-membrane +// Bessel-root frequency ratios 1 : 1.594 : 2.136 : 2.296 : 2.653 : 2.918 +// (modes 01, 11, 21, 02, 31, 12 of J_m roots — Fletcher & Rossing, +// "The Physics of Musical Instruments", ch. 18). Each mode is an +// exponentially decaying oscillator struck through a short raised-cosine +// contact pulse (1.5–3 ms; harder hits = stiffer, shorter contact). +// +// THE KEY NONLINEARITY: membrane tension is not constant. A displaced +// head is stretched, so instantaneous tension rises with total +// displacement energy and every modal frequency scales with sqrt(T): +// +// f_k(t) = r_k · f0 · sqrt(1 + Γ · E(t)), +// E(t) = vel² · Σ_k (w_k · s(t) · e^(-t/τ_k))² / Σ_k w_k² +// +// so a hard hit starts SHARP (≈2.6× f0 at vel=1) and glides down to the +// note fundamental as the head relaxes — the kick's pitch-drop comes from +// membrane physics (tension modulation in the timpani/drumhead literature, +// cf. Bilbao, "Numerical Sound Synthesis", ch. 11–12), not a scripted +// sine sweep. Velocity → strike force → loudness AND glide depth AND +// brightness (mode-weight tilt) AND decay tightness (nonlinear losses). +// A few ms of hash-noise "mallet contact" is bandpassed near the upper +// mode region for the attack tick. +// +// CITATION / LINEAGE +// - N. H. Fletcher & T. D. Rossing, "The Physics of Musical Instruments", +// 2nd ed., Springer 1998 — ch. 18 circular-membrane mode frequencies + +// tension-dependent pitch of struck drumheads. +// - S. Bilbao, "Numerical Sound Synthesis", Wiley 2009 — ch. 11–12, +// nonlinear tension-modulated membranes/plates and their pitch glide. +// +// WHAT MAKES IT NOVEL HERE +// hellsine's gabber kick and percussion.mjs's 808 both SCRIPT the pitch +// drop as an explicit sine sweep; nullnoise carves filtered noise with no +// modes at all; gm_synth percussion is a GM kit reimplementation. memkick +// is the only AC percussion whose drop is an emergent property of a +// physical model: the glide depth, glide speed, and spectrum all co-vary +// with strike force through one energy variable, and the body is an +// inharmonic Bessel stack rather than a lone sine. No lineage overlap +// with novelizer batch 1 (scanner/pulsar/chaosfm/frictus/vosim/twomass). +// +// PARAMETER MAP +// MODE_RATIO[6] 1, 1.594, 2.136, 2.296, 2.653, 2.918 Bessel ratios +// mode weights w_k = r_k^-(2.2 − 1.0·vel) harder hit = brighter head +// decay τ0 = (0.060 − 0.026·vel)·(65.4/f0)^0.25 s (amp const); +// τ_k = τ0 / (1 + 0.9·(r_k − 1)) upper modes die fast +// tension Γ = 6.0 → vel=1 starts sqrt(7) ≈ 2.65× sharp, settles +// to f0 within ~80 ms; vel=0.5 starts only 1.58× sharp +// contact raised-cosine strike window, T = 3.2 − 1.6·vel ms +// click hash-noise burst, τ 1.2 ms, SVF bandpass at +// clamp(22·f0, 900, 3800) Hz, level 0.11·vel^1.6 +// per-note hash ±0.2% mode detune + click seed (deterministic) +// amp 0.9 · vel^1.4 (velocity accents) + +#include "../novelizer.h" + +#define NMODES 6 +static const double MODE_RATIO[NMODES] = { + 1.0, 1.594, 2.136, 2.296, 2.653, 2.918 +}; + +#define TENSION_GAMMA 6.0 +#define CLICK_TAU 0.0012 +#define CLICK_LEN 0.012 + +/* ── deterministic hashing (no libc rand) ─────────────────────────── */ +static inline uint32_t mk_hash32(uint32_t x) { + x ^= x >> 16; x *= 0x7feb352dU; + x ^= x >> 15; x *= 0x846ca68bU; + x ^= x >> 16; + return x; +} +static inline double mk_hash01(uint32_t x) { + return mk_hash32(x) / 4294967296.0; +} + +static void render_note(const NvNote *n, int idx, float *out, int nframes) { + int s0 = (int)(n->start * NV_SR); + if (s0 >= nframes) return; + double f0 = n->freq; + double vel = n->vel; + if (vel < 0.0) vel = 0.0; + if (vel > 1.0) vel = 1.0; + + uint32_t seed = mk_hash32((uint32_t)idx * 2654435761u ^ + (uint32_t)(n->start * 48000.0) ^ + (uint32_t)(f0 * 64.0)); + + /* register scaling: smaller (higher-pitched) heads ring shorter */ + double reg = pow(65.406 / f0, 0.25); + if (reg > 1.6) reg = 1.6; + if (reg < 0.25) reg = 0.25; + + /* harder hit -> more nonlinear loss -> tighter decay */ + double tau0 = (0.060 - 0.026 * vel) * reg; + double bright = 2.2 - 1.0 * vel; /* mode-weight rolloff exponent */ + + double w[NMODES], tau[NMODES], det[NMODES], ph[NMODES]; + double s0w = 0.0; + for (int k = 0; k < NMODES; k++) { + w[k] = pow(MODE_RATIO[k], -bright); + tau[k] = tau0 / (1.0 + 0.9 * (MODE_RATIO[k] - 1.0)); + det[k] = 1.0 + 0.004 * (mk_hash01(seed + 7u * (uint32_t)k) - 0.5); + ph[k] = 0.0; + s0w += w[k] * w[k]; + } + + double tstrike = 0.0032 - 0.0016 * vel; /* raised-cosine contact time */ + double amp = 0.9 * pow(vel, 1.4); + + /* mallet-contact click: hash noise through an SVF bandpass */ + double fc = 22.0 * f0; + if (fc < 900.0) fc = 900.0; + if (fc > 3800.0) fc = 3800.0; + double f1 = 2.0 * sin(M_PI * fc / NV_SR); + double svf_lp = 0.0, svf_bp = 0.0; + double click_amp = 0.11 * pow(vel, 1.6); + uint32_t rng = seed ^ 0x9e3779b9u; + + /* render to -60 dB of the slowest mode (+ margin), never past nframes */ + int len = (int)(6.9 * tau0 * NV_SR) + (int)(0.06 * NV_SR); + if (len > nframes - s0) len = nframes - s0; + + for (int i = 0; i < len; i++) { + double t = (double)i / NV_SR; + double s = (t < tstrike) ? 0.5 * (1.0 - cos(M_PI * t / tstrike)) : 1.0; + + /* modal envelopes + total displacement energy (drives tension) */ + double ek[NMODES], E = 0.0; + for (int k = 0; k < NMODES; k++) { + ek[k] = w[k] * s * exp(-t / tau[k]); + E += ek[k] * ek[k]; + } + E *= vel * vel / s0w; + double g = sqrt(1.0 + TENSION_GAMMA * E); /* f_k scales with sqrt(T) */ + + double y = 0.0; + for (int k = 0; k < NMODES; k++) { + y += ek[k] * sin(NV_TAU * ph[k]); + ph[k] += MODE_RATIO[k] * det[k] * f0 * g / NV_SR; + if (ph[k] >= 1.0) ph[k] -= 1.0; + } + + if (t < CLICK_LEN) { + double ce = (t < 0.0003) ? t / 0.0003 + : exp(-(t - 0.0003) / CLICK_TAU); + rng = rng * 1664525u + 1013904223u; + double nz = ((double)(rng >> 9) / 4194304.0) - 1.0; + svf_lp += f1 * svf_bp; + double hp = nz - svf_lp - 0.8 * svf_bp; + svf_bp += f1 * hp; + y += click_amp * ce * svf_bp; + } + + out[s0 + i] += (float)(amp * y); + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_note(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { return nv_main(argc, argv, "memkick", render); } diff --git a/pop/novelizer/c/voices/wiresnare.c b/pop/novelizer/c/voices/wiresnare.c new file mode 100644 index 000000000..852aa17b7 --- /dev/null +++ b/pop/novelizer/c/voices/wiresnare.c @@ -0,0 +1,237 @@ +// wiresnare.c — coupled membrane + snare-wire contact model. +// +// TECHNIQUE +// A modal batter head (7 inharmonic circular-membrane modes, fundamental +// at the note frequency octave-folded into snare register) coupled to a +// bank of 12 light mass-spring snare WIRES resting just below the head. +// The coupling is a one-sided (unilateral) contact nonlinearity: when a +// wire's displacement crosses the head's while approaching, it reflects +// off the moving head with restitution e = 0.5 — a single-sample impulse +// (broadband by construction) that is also injected into every head mode. +// The buzz is EMERGENT: the stick rings the head, the head throws the +// wires, the wires chatter back with velocity-dependent density and a +// raggedy natural decay — there is no noise generator anywhere here. +// (A Hertzian k·p^1.5 penalty contact was tried first; under explicit +// per-sample integration it pumps energy and the buzz self-sustains. +// The impulsive restitution form is strictly dissipative, so the rattle +// is guaranteed to die with the head.) +// +// CITATION / LINEAGE +// - T. D. Rossing, I. Bork, H. Zhao, D. O. Fystrom, "Acoustics of snare +// drums", J. Acoust. Soc. Am. 92(1), 1992 — two-membrane snare physics; +// snare-wire rattle as repeated unilateral head/wire collisions. +// - S. Bilbao, A. Torin, V. Chatziioannou, "Numerical modeling of +// collisions in musical instruments", Acta Acustica 101(1), 2015 — +// unilateral head/wire collision modeling incl. the snare-drum example; +// their core demand (the contact scheme must not create energy) is met +// here with an impulsive restitution resolution instead of their +// penalty potential, which is not stable under naive explicit Euler. +// +// WHAT MAKES IT NOVEL HERE +// Every existing AC snare-ish voice makes its "snap" from a noise source: +// nullnoise carves filtered pink/white/velvet bursts, percussion.mjs and +// gm_synth shape noise through envelopes/filters, hellsine has no snare at +// all. wiresnare has NO noise generator — its rattle is a deterministic +// chaotic contact process, so the tail audibly granulates (discrete click +// striations that thin out as the head settles) instead of hissing, and +// velocity buys buzz DENSITY and DURATION, not just level. Vs batch 1: +// twomass is also a collision model, but of two masses inside one +// oscillator; here collision is the COUPLING between a resonant body and +// a 12-element rattle bank (declared lineage: same penalty-force family). +// +// PARAMETER MAP +// fold note freq octave-folded up to >= 120 Hz (head fundamental) +// MODE ratios 1, 1.593, 2.135, 2.295, 2.653, 2.917, 3.155 (+/-0.6% hash) +// ideal circular-membrane ratios (Rossing) +// mode T (amp e-fold) 90 ms fundamental, ~ratio^-0.6 above +// strike 1.0 ms raised-cosine push, force = vel^1.1, downward +// WIRES (12) natural freq 320..1750 Hz (hash log-spread), ~60 ms damp +// gap_i 0.04..0.30 head units (hash) — sets buzz threshold, so +// soft hits only reach the closest wires (fewer, sparser +// contacts) and hard hits engage the whole bank +// contact impulse j = (1+e)·v_rel, e = 0.5, clamp 1.0; head +// injection 0.02·j (well below regeneration threshold) +// output 0.70*head + 4.0*highpassed contact clicks, DC-blocked +// note window 900 ms per hit (additive overlap, 10 ms end fade) + +#include "../novelizer.h" + +#define N_MODES 7 +#define N_WIRES 12 +#define NOTE_SEC 0.90 +#define STRIKE_SEC 0.001 +#define FOLD_MIN 120.0 + +/* ideal circular membrane mode ratios: (01)(11)(21)(02)(31)(12)(41) */ +static const double mode_ratio[N_MODES] = { + 1.0, 1.593, 2.135, 2.295, 2.653, 2.917, 3.155 +}; +static const double mode_gain[N_MODES] = { + 1.0, 0.62, 0.46, 0.40, 0.31, 0.26, 0.21 +}; +static const double mode_strike[N_MODES] = { + 1.0, 0.92, 0.85, 0.80, 0.74, 0.66, 0.58 +}; + +/* contact: impulsive unilateral collision with restitution < 1, so every + wire/head impact strictly LOSES energy — the buzz must decay with the + head. Each impact is a single-sample impulse: broadband by construction. */ +#define RES 0.50 /* coefficient of restitution */ +#define J_MAX 1.0 /* impulse clamp (stability) */ +#define INJ 0.02 /* impact impulse -> head-mode injection (kept well + below the regeneration threshold: the wire->head-> + wire loop must have gain < 1 or the buzz self- + sustains — measured, not guessed) */ + +/* mix */ +#define HEAD_MIX 0.70 +#define CLICK_MIX 4.0 +#define CLICK_HP_K 0.12 /* one-pole HP on the click bus (~1 kHz) */ + +/* ── deterministic per-note hash (splitmix64) ─────────────────────── */ +static uint64_t sm64(uint64_t x) { + x += 0x9E3779B97F4A7C15ULL; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ULL; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBULL; + return x ^ (x >> 31); +} +static double h01(uint64_t seed, int slot) { /* uniform 0..1 */ + return (double)(sm64(seed + (uint64_t)slot * 0x632BE59BD9B4E019ULL) >> 11) + / 9007199254740992.0; +} + +typedef struct { /* complex-rotation resonator (one head mode) */ + double cr, ci; /* state */ + double rw, iw; /* rotation r*e^{jw} */ +} Mode; + +typedef struct { /* one snare wire: light damped mass-spring */ + double pos, vel; + double k2; /* (2*pi*fw/SR)^2 spring constant */ + double damp; /* velocity retention per sample */ + double rest; /* equilibrium = -gap below the head */ +} Wire; + +static void render_note(const NvNote *n, int idx, float *out, int nframes) { + int s0 = (int)(n->start * NV_SR); + if (s0 >= nframes) return; + int len = (int)(NOTE_SEC * NV_SR); + if (s0 + len > nframes) len = nframes - s0; + int fade = (int)(0.010 * NV_SR); + + /* octave-fold the note fundamental up into drum-head register */ + double f0 = n->freq; + while (f0 < FOLD_MIN) f0 *= 2.0; + + uint64_t seed = sm64((uint64_t)idx * 0x100000001B3ULL + ^ (uint64_t)(n->freq * 1024.0)); + + /* head modes */ + Mode md[N_MODES]; + for (int k = 0; k < N_MODES; k++) { + double jit = 1.0 + 0.012 * (h01(seed, 100 + k) - 0.5); /* +/-0.6% */ + double fk = f0 * mode_ratio[k] * jit; + if (fk > 0.45 * NV_SR) fk = 0.45 * NV_SR; + double tau = 0.090 * pow(mode_ratio[k], -0.6); /* amp e-fold, sec */ + double r = exp(-1.0 / (tau * NV_SR)); + double w = NV_TAU * fk / NV_SR; + md[k].cr = md[k].ci = 0.0; + md[k].rw = r * cos(w); + md[k].iw = r * sin(w); + } + + /* snare wires: rest just below the head, hash-spread freq + gap */ + Wire wr[N_WIRES]; + for (int i = 0; i < N_WIRES; i++) { + double u = ((double)i + h01(seed, 200 + i)) / N_WIRES; /* stratified */ + double fw = 320.0 * pow(1750.0 / 320.0, u); /* log spread */ + double ph = NV_TAU * fw / NV_SR; + wr[i].k2 = ph * ph; + wr[i].damp = exp(-1.0 / (0.060 * NV_SR)); /* ~60 ms free e-fold */ + wr[i].rest = -(0.04 + 0.26 * h01(seed, 300 + i)); + wr[i].pos = wr[i].rest; + wr[i].vel = 0.0; + } + + /* strike: raised-cosine downward push, unit-area normalized */ + int sl = (int)(STRIKE_SEC * NV_SR); + if (sl < 4) sl = 4; + double force = pow(n->vel, 1.1); + double amp = pow(n->vel, 1.15); /* output loudness */ + + double inj = 0.0; /* wire->head injection (1-sample delay) */ + double hp_lp = 0.0; /* click-bus highpass state */ + double dc_x1 = 0.0, dc_y1 = 0.0; + double hprev = 0.0; /* head displacement history for head velocity */ + + for (int i = 0; i < len; i++) { + double strike = 0.0; + if (i < sl) + strike = -force * (2.0 / sl) * 0.5 * (1.0 - cos(NV_TAU * i / sl)); + + /* head modes: rotate, then take input (strike + wire kicks) */ + double h = 0.0; + for (int k = 0; k < N_MODES; k++) { + double in = strike * mode_strike[k] + inj * INJ; + double nr = md[k].cr * md[k].rw - md[k].ci * md[k].iw; + double ni = md[k].cr * md[k].iw + md[k].ci * md[k].rw; + md[k].cr = nr; + md[k].ci = ni + in; + h += mode_gain[k] * md[k].ci; + } + + /* wires: free oscillation + unilateral impulsive collision against + the head (Rossing's rattle; restitution < 1 keeps it dissipative) */ + double hvel = h - hprev; + hprev = h; + inj = 0.0; + double click = 0.0; + for (int w = 0; w < N_WIRES; w++) { + wr[w].vel += -wr[w].k2 * (wr[w].pos - wr[w].rest); + wr[w].vel *= wr[w].damp; + wr[w].pos += wr[w].vel; + double p = wr[w].pos - h; /* head swings down into the wire */ + if (p > 0.0) { + double vrel = wr[w].vel - hvel; /* approach speed */ + if (vrel > 0.0) { + double j = (1.0 + RES) * vrel; /* impulse magnitude */ + if (j > J_MAX) j = J_MAX; + wr[w].vel = hvel - RES * vrel; /* reflect off the moving head */ + inj += j; /* head hears the impact next sample */ + click += j; + } + wr[w].pos = h; /* resolve penetration */ + } + /* stability clamps (never expected to trigger) */ + if (wr[w].pos > 5.0) wr[w].pos = 5.0; + else if (wr[w].pos < -5.0) wr[w].pos = -5.0; + if (wr[w].vel > 3.0) wr[w].vel = 3.0; + else if (wr[w].vel < -3.0) wr[w].vel = -3.0; + } + + /* click bus: one-pole highpass keeps the snap, drops thump */ + hp_lp += CLICK_HP_K * (click - hp_lp); + double snap = click - hp_lp; + + double x = HEAD_MIX * h + CLICK_MIX * snap; + + /* DC blocker */ + double y = x - dc_x1 + 0.9995 * dc_y1; + dc_x1 = x; + dc_y1 = y; + + double g = amp * 0.35; + if (i > len - fade) g *= (double)(len - i) / fade; /* end-of-window fade */ + out[s0 + i] += (float)(y * g); + } +} + +static void render(const NvMelody *m, float *out, int nframes) { + for (int j = 0; j < m->count; j++) { + const NvNote *n = &m->notes[j]; + if (n->freq <= 0) continue; + render_note(n, j, out, nframes); + } +} + +int main(int argc, char **argv) { return nv_main(argc, argv, "wiresnare", render); }