Procedural music generator for ska/reggae/rock (and other genres). It deterministicly seeds a portable PRNG from an 8-hex tag, builds a song structure (tempo, key, progression, bass/skank/organ/lead/horns/drums), synthesizes pitched voices with unison oscillators + SVF filters and a drum kit, applies a master soft-clipper and Schroeder-style reverb, and emits WAV or PCM sample buffers. It supports chunked rendering so pitched synthesis can be parallelized.
using System;
using System.Collections.Generic;
namespace Skafinity;
/// <summary>
/// Deterministic procedural ska / reggae-rock track generator (issue: procedural music).
///
/// A player's 8-hex <c>player_tag</c> seeds a portable PRNG (xmur3 → mulberry32);
/// the PRNG drives every musical choice (tempo, key, progression, bass / skank /
/// organ / lead / drum patterns) so the SAME tag always produces the SAME song.
/// Output is interleaved stereo 16-bit PCM (for SoundStream) or a WAV (debug).
///
/// PORTABILITY (web parity): the *composition* is whatever the PRNG emits — a web
/// client reproduces the same arrangement by mirroring xmur3 + mulberry32 and this
/// file's note-selection order, using the same <see cref="Config"/> values. Synthesis
/// (oscillators/filters below) only needs matching for bit-identical audio.
///
/// Synthesis: subtractive — unison-detuned oscillators through a resonant low-pass
/// state-variable filter with a cutoff envelope (warm, not "8-bit"); full synth drum
/// kit (kick/snare/toms/hats/crash + fills). Default voicing aims for a Sublime vibe:
/// laid-back reggae-rock tempo, bass-forward, prominent clean skank + organ bubble.
/// </summary>
public sealed class MusicGen
{
public sealed class Config
{
// Genre — selects the instrument set + arrangement. 0 = Ska (bass/skank/organ/lead/
// horns/drums), 1 = Rock (drums/bass/rhythm-gtr/lead-gtr). New genres append here.
public int Genre = 0;
// Output
public int SampleRate = 44100;
public float TargetSeconds = 80f; // (legacy) song length now follows the structure
public int Bars = 64; // fallback if TargetSeconds <= 0
// Tempo — main is laid-back reggae-rock; Fast is an uptempo ska feel.
public int BpmMin = 130, BpmMax = 185;
public float FastChance = 0.30f;
public int FastBpmMin = 150, FastBpmMax = 168;
public float Swing = 0.14f, FastSwing = 0.05f;
// Mix (per-voice gain pre-master) — the six "volume" sliders are normalized:
// same 0..1.5 range and the same 1.0 default (flat mix), tune from there.
public float BassVol = 1.00f;
public float SkankVol = 1.00f;
public float OrganVol = 1.00f;
public float MelodyVol = 1.00f;
public float HornVol = 1.00f;
// Per-part kit trims. The kit is balanced for EQUAL PERCEIVED LOUDNESS internally
// (see *Balance consts below), so these knobs all share a 1.0 default — every piece
// reads at the same volume out of the box and these are pure user trims around that.
public float KickVol = 1.00f;
public float SnareVol = 1.00f;
public float TomVol = 1.00f;
public float HatVol = 1.00f;
public float CrashVol = 1.00f;
public float DrumVol = 1.00f; // master gain over the whole kit
// Drum "tone" — toms↔cymbals bias for the PART. 0 = boom (fills/decoration lean to
// toms, cymbals pulled back), 0.5 = neutral, 1 = bright (fills/decoration lean to
// cymbals, toms pulled back). Drives both what's played and a gentle per-voice gain lean.
public float DrumTone = 0.5f;
// Drum "drive" — pull↔push timing feel (replaces the old DrumPush magnitude roll).
// 0 = lay back behind the beat, 0.5 = dead-on, 1 = push ahead of the beat.
public float DrumDrive = 0.5f;
// Rock instruments (Genre 1). Bass + drums reuse the shared knobs above.
// KEYS — the offbeat-chord comp (was labelled "rhythm guitar", but it always read as a
// keyboard, so it's named for what it sounds like). Power-chord comping on every eighth.
public float KeysVol = 1.00f;
public float KeysCutoff = 1700f; // Hz low-pass (darker wall)
public float KeysDrive = 3.2f; // distortion amount (tanh drive)
public float KeysChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug
// RHYTHM GTR — twangy distorted power chords. Shares the lead guitar's voice but strums
// chords at a lower base distortion than the lead.
public float RhythmGtrVol = 1.00f;
public float RhythmGtrCutoff = 2600f; // Hz low-pass on the rhythm guitar
public float RhythmGtrDrive = 2.8f; // distortion amount (tanh drive) — under the lead
public float RhythmGtrChug = 0.5f; // 0 = ringing chords, 1 = tight palm-mute chug
// LEAD GTR — twangy, heavily distorted single-note lead.
public float LeadGtrVol = 1.00f;
public float LeadGtrCutoff = 2600f; // Hz low-pass on the lead guitar
public float LeadGtrDrive = 5.0f; // distortion amount (tanh drive) — new floor of the DISTORTION knob
public float LeadGtrBend = 0.30f; // rock lead "bendiness" 0..1 — propensity to bend up into notes and scoop
// Tone — low drives + filtering for warmth; detune for width.
public float Detune = 14f; // cents, unison spread
public float BassCutoff = 380f; // Hz low-pass on bass
public float SkankCutoff = 3000f; // Hz low-pass on skank
public float SkankHighpass = 500f;// Hz high-pass to thin the skank ("skank bite")
public float SkankChop = 0.5f; // skank chop length as a fraction of an eighth
public float LeadCutoff = 3200f; // Hz low-pass on lead
public float OrganCutoff = 1400f; // Hz low-pass on the organ bubble
public float OrganVibrato = 5.5f; // organ bubble vibrato depth
public float HornCutoff = 3200f; // Hz low-pass on the backing horns
public float Resonance = 1.0f; // SVF damping (lower = more resonant)
public float BassDrive = 1.5f;
public float SkankDrive = 1.3f;
public float MelodyDrive = 1.3f;
public float HornDrive = 1.4f;
public float MasterDrive = 1.1f;
public float MasterPeak = 0.95f;
// Master room reverb — a touch of stereo space so the mix reads with depth
// instead of dry/"16-bit". Wet = blend, Decay = tail length (0..1).
public float MasterReverb = 0.16f;
public float ReverbDecay = 0.5f;
// Feel
public float OctavePopChance = 0.30f;
public float OrganBubbleChance = 0.55f;
public float KickSyncChance = 0.25f;
public float GhostSnareChance = 0.35f;
public float FillChance = 0.6f; // drum fill at phrase ends
public float DrumBusy = 0.6f; // 0..1 overall kit activity: 16th hats, ghosts, kick syncopation
public float TripletChance = 0.06f; // 0..0.2 chance of triplet/16th ornament in fills / lead runs (potent)
public float BassTriplets = 0.06f; // 0..0.1 bass-only 16th/triplet ornament rate (own knob)
public float MelodyRestChance = 0.30f;
public float MelodyLeapChance = 0.18f;
public float MelodyVibrato = 5.0f;
// Stereo — how far off-center the lead sits; horns spread around it.
public float PanAmount = 0.4f;
// Lead instrument weights (RNG picks one; ForceInstrument overrides:
// -1 = RNG, 0=Trumpet 1=Sax 2=Organ 3=Trombone).
public float TrumpetWeight = 1.0f;
public float SaxWeight = 1.0f;
public float OrganWeight = 0.8f;
public float TromboneWeight = 0.4f;
public int ForceInstrument = -1;
// Backing horn section
public float HornSectionChance = 0.5f;
public float HornDensity = 0.35f;
}
readonly Config _c;
readonly int _sr;
// Drums are short transients fighting a sustained melodic bed; this baseline boost
// lets the kit sit in the mix at DRUMS = 1.0 (parity with the other voice sliders).
const float KitPresence = 2.0f;
readonly float _drumGain; // master kit gain — straight 0..1.5 slider × KitPresence baseline
// Per-voice loudness normalization. The kit pieces synthesise at very different raw
// levels (a kick is huge, a hat is thin noise), so these bake in the level differences
// that used to live in the per-part Vol defaults. With each applied here, every piece
// reads at the same perceived volume when its Vol knob sits at the shared 1.0 default.
// Kick is the 1.0 reference; tune these to re-balance the kit, not the Vol defaults.
const float KickBalance = 1.00f;
const float SnareBalance = 0.70f;
const float TomBalance = 0.60f;
const float HatBalance = 0.22f;
const float CrashBalance = 0.35f;
float[] _bufL, _bufR;
MusicGen( Config c ) { _c = c ?? new Config(); _sr = _c.SampleRate; _drumGain = Math.Clamp( _c.DrumVol, 0f, 1.5f ) * KitPresence; }
public const int Channels = 2;
public static byte[] Generate( string tag, Config cfg = null )
{
var g = new MusicGen( cfg );
return g.EncodeWav( g.Compose( tag ) );
}
public static short[] GenerateSamples( string tag, Config cfg, out int sampleRate )
{
var g = new MusicGen( cfg );
float gain = g.Compose( tag );
sampleRate = g._sr;
return g.ToShorts( gain );
}
// ── Chunked generation (parallel synthesis) ──
// Composition + drum synthesis are sequential (RNG-bound); pitched-voice synthesis
// pulls no RNG, so the caller can split it across worker threads. Flow:
// var g = MusicGen.BeginPlan( tag, cfg ); // sequential plan + drums
// parallel-for window in 0..g.TotalSamples: g.RenderPitchedRange( from, to );
// short[] mono = g.FinishMono(); // master + downmix
public static MusicGen BeginPlan( string tag, Config cfg )
{
var g = new MusicGen( cfg );
g.ComposePlan( tag );
return g;
}
public int TotalSamples => _bufL?.Length ?? 0;
public int SampleRate => _sr;
/// <summary>Master-normalize and downmix to mono. Call after every
/// <see cref="RenderPitchedRange"/> window has finished.</summary>
public short[] FinishMono()
{
float gain = Master();
int n = _bufL.Length;
var mono = new short[n];
for ( int i = 0; i < n; i++ )
mono[i] = ToS16( (_bufL[i] + _bufR[i]) * 0.5f * gain );
return mono;
}
const int EighthsPerBar = 8;
// ── Portable PRNG (mirror exactly in the web client) ──
static uint Xmur3( string str )
{
uint h = 1779033703u ^ (uint)str.Length;
for ( int i = 0; i < str.Length; i++ )
{
h = unchecked( (h ^ str[i]) * 3432918353u );
h = (h << 13) | (h >> 19);
}
h = unchecked( (h ^ (h >> 16)) * 2246822507u );
h = unchecked( (h ^ (h >> 13)) * 3266489909u );
return h ^ (h >> 16);
}
sealed class Rng
{
uint _a;
public Rng( uint seed ) { _a = seed; }
public float Next()
{
_a = unchecked( _a + 0x6D2B79F5u );
uint t = _a;
t = unchecked( (t ^ (t >> 15)) * (t | 1u) );
t ^= unchecked( t + (t ^ (t >> 7)) * (t | 61u) );
return (t ^ (t >> 14)) / 4294967296f;
}
public int Int( int n ) => n <= 0 ? 0 : Math.Min( n - 1, (int)(Next() * n) );
public bool Chance( float p ) => Next() < p;
public T Pick<T>( T[] arr ) => arr[Int( arr.Length )];
}
// ── Harmony ──
// Major-leaning scales (Sublime / reggae sit in major & mixolydian mostly).
static readonly int[][] Scales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted twice)
new[] { 0, 2, 3, 5, 7, 9, 10 }, // dorian
};
static readonly int[][] Progressions =
{
new[] { 0, 4, 5, 3 }, // I–V–vi–IV
new[] { 0, 3, 4, 4 }, // I–IV–V–V
new[] { 0, 5, 3, 4 }, // I–vi–IV–V
new[] { 0, 6, 3, 0 }, // I–bVII–IV–I (mixolydian)
new[] { 5, 3, 0, 4 }, // vi–IV–I–V
new[] { 0, 3, 0, 4 }, // I–IV–I–V
};
// Bass patterns: semitone offsets from the chord root per eighth; -99 = rest
// (note sustains to the next onset). Slot 7 is the "approach" → walks to the
// next chord. Mix of melodic / one-drop / rocking / busy ska.
const int Rest = -99, Approach = 99;
static readonly int[][] BassPatterns =
{
new[] { 0, Rest, 0, 12, Rest, 7, 5, Approach }, // sublime melodic
new[] { Rest, Rest, 0, Rest, 0, Rest, 7, Approach }, // one-drop spacey
new[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // rocking
new[] { 0, 12, 0, 7, 0, 12, 7, Approach }, // busy ska
new[] { 0, Rest, 0, Rest, 5, Rest, 7, Approach }, // root–fifth
};
// ── Rock harmony (Genre 1) ──
// Darker, power-chord-friendly modes (minor / dorian / mixolydian) so rock doesn't
// share ska's bright major themes. Picked with the SAME RNG draw as the ska tables, so
// ska songs are byte-identical — only genre 1 reads these.
static readonly int[][] RockScales =
{
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian (classic-rock major-ish)
new[] { 0, 2, 3, 5, 7, 9, 10 }, // dorian
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted twice)
};
// Degrees are read against the (often minor) scale, so 5 = ♭VI, 6 = ♭VII, 3 = iv, 4 = v.
static readonly int[][] RockProgressions =
{
new[] { 0, 6, 5, 6 }, // i–♭VII–♭VI–♭VII (driving rock vamp)
new[] { 0, 5, 6, 0 }, // i–♭VI–♭VII–i
new[] { 0, 6, 3, 0 }, // i–♭VII–IV–i (mixolydian rock)
new[] { 0, 3, 6, 0 }, // i–iv–♭VII–i
new[] { 0, 0, 6, 6 }, // i / ♭VII riff vamp
new[] { 0, 3, 4, 0 }, // i–iv–v–i
};
// Driving root/octave eighths that lock to the kick — the rock engine room, vs ska's
// syncopated off-beat one-drop bass.
static readonly int[][] RockBassPatterns =
{
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight eighth chug
new[] { 0, Rest, 0, Rest, 0, Rest, 0, Approach },// quarter-note pulse
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes
new[] { 0, 0, 7, 0, 0, 0, 7, Approach }, // root–fifth gallop
new[] { 0, Rest, 0, 0, Rest, 0, 12, Approach }, // syncopated driver
};
// ── Country harmony (Genre 2) ──
// Bright and major — country lives in major / mixolydian. Same RNG draws as the other
// tables, so songs in other genres are untouched.
static readonly int[][] CountryScales =
{
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 10 }, // mixolydian
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major
new[] { 0, 2, 4, 5, 7, 9, 11 }, // major (weighted)
};
static readonly int[][] CountryProgressions =
{
new[] { 0, 3, 4, 0 }, // I–IV–V–I (the country backbone)
new[] { 0, 0, 4, 4 }, // I–V vamp
new[] { 0, 3, 0, 4 }, // I–IV–I–V
new[] { 0, 4, 5, 3 }, // I–V–vi–IV
new[] { 0, 4, 0, 4 }, // I–V two-chord
};
// "Boom-chick" alternating root/fifth on the beats (the guitar/snare take the off "chick"),
// walking up to the next chord on the approach.
static readonly int[][] CountryBassPatterns =
{
new[] { 0, Rest, 7, Rest, 0, Rest, 7, Approach }, // alternating root–fifth
new[] { 0, Rest, 7, Rest, 12, Rest, 7, Approach }, // root–fifth with the octave
new[] { 0, Rest, 7, Rest, 0, Rest, 5, Approach }, // root–fifth, lean on the 4th
new[] { 0, Rest, 4, Rest, 7, Rest, 5, Approach }, // walking-ish
};
// ── Metal harmony (Genre 3) ──
// Dark and tight — natural minor / phrygian / harmonic minor for the menacing power-chord
// riffs. Same RNG draws as the other tables.
static readonly int[][] MetalScales =
{
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (aeolian)
new[] { 0, 1, 3, 5, 7, 8, 10 }, // phrygian (the metal mode)
new[] { 0, 2, 3, 5, 7, 8, 11 }, // harmonic minor
new[] { 0, 2, 3, 5, 7, 8, 10 }, // natural minor (weighted)
};
// Degrees read against the (minor) scale: 5 = ♭VI, 6 = ♭VII, 1 = ♭II, 3 = iv.
static readonly int[][] MetalProgressions =
{
new[] { 0, 5, 6, 0 }, // i–♭VI–♭VII–i
new[] { 0, 6, 5, 6 }, // i–♭VII–♭VI–♭VII (driving)
new[] { 0, 1, 0, 6 }, // i–♭II–i–♭VII (phrygian menace)
new[] { 0, 0, 5, 6 }, // i pedal → ♭VI–♭VII
new[] { 0, 6, 3, 0 }, // i–♭VII–iv–i
};
// Driving roots locked to the double-kick; octave pushes for the gallop.
static readonly int[][] MetalBassPatterns =
{
new[] { 0, 0, 0, 0, 0, 0, 0, Approach }, // straight chug
new[] { 0, 0, 12, 0, 0, 0, 12, Approach }, // root with octave pushes
new[] { 0, 0, 0, 12, 0, 0, 0, Approach }, // syncopated octave
new[] { 0, Rest, 0, 0, Rest, 0, 0, Approach }, // syncopated driver
};
// The genre's harmony tables (one RNG Pick each, so other genres stay byte-identical).
static int[][] ScalesFor( int g ) => g switch
{ 1 => RockScales, 2 => CountryScales, 3 => MetalScales, _ => Scales };
static int[][] ProgressionsFor( int g ) => g switch
{ 1 => RockProgressions, 2 => CountryProgressions, 3 => MetalProgressions, _ => Progressions };
static int[][] BassPatternsFor( int g ) => g switch
{ 1 => RockBassPatterns, 2 => CountryBassPatterns, 3 => MetalBassPatterns, _ => BassPatterns };
int[] _scale, _prog;
int _rootMidi;
Instrument _lead;
float _leadPan;
bool _hasHorns;
bool[] _hornMask;
int[] _bassPat;
int _drumStyle; // 0 one-drop, 1 steppers, 2 straight backbeat
int[] _kickAccents = Array.Empty<int>(); // per-song backbeat kick accents (see BackbeatKickAccents)
bool _ride; // per-song: ride cymbal drives the eighth pulse instead of closed hats
bool _organBubble;
bool _fast;
int _genre; // 0 ska, 1 rock
string _tag; // the per-song seed string, reused to seed per-section streams
int _drumPush; // per-song-constant kit timing bias in samples (− ahead / + back)
float _drumTone = 0.5f; // DrumTone 0..1 → toms↔cymbals CONTENT bias in fills/groove decoration
float _drumLowMul = 1f; // DrumTone → kick/tom gain lean (gentle, on top of the content bias)
float _drumHighMul = 1f; // DrumTone → hat/cymbal gain lean (gentle, on top of the content bias)
// ── Song structure ──
// A song is an ordered list of sections. Hardcoded for now (will be RNG-generated once
// there are more part types); the fixed run is intro → chorus → verse(0) → chorus →
// verse(1) → chorus → ending. Non-lead voices are seeded by section TYPE so every chorus
// (and both verses) play identical backing; the lead is seeded by type + verse index so
// it evolves across the Nth verse; the section-end fill is seeded by absolute index so
// every section closes with a different fill.
enum Section { Intro, Chorus, Verse, Ending }
readonly struct Part
{
public readonly Section Type; public readonly int Bars; public readonly int VerseIndex;
public Part( Section t, int bars, int verse ) { Type = t; Bars = bars; VerseIndex = verse; }
}
static List<Part> BuildStructure() => new()
{
new Part( Section.Intro, 4, 0 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Verse, 8, 0 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Verse, 8, 1 ),
new Part( Section.Chorus, 8, 0 ),
new Part( Section.Ending, 4, 0 ),
};
static string SectionKey( Section s ) => s switch
{
Section.Intro => "intro",
Section.Chorus => "chorus",
Section.Verse => "verse",
_ => "ending",
};
// Single-threaded generation (used by Generate / GenerateSamples). The controller
// uses the chunked path instead (BeginPlan → parallel RenderPitchedRange → FinishMono).
float Compose( string tag )
{
ComposePlan( tag );
RenderPitchedRange( 0, _bufL.Length );
return Master();
}
// Sequential planning pass: RNG composition + drum synthesis written straight into
// the buffer, while every pitched note is collected as an event (rendered later,
// possibly in parallel). RNG draw order is identical to the old inline render —
// RenderPatch now only enqueues, and it never pulled RNG anyway.
void ComposePlan( string tag )
{
_events.Clear();
_tag = string.IsNullOrEmpty( tag ) ? "rotaliate" : tag;
_genre = Math.Clamp( _c.Genre, 0, 3 );
var rng = new Rng( Xmur3( _tag.ToLowerInvariant() ) );
_fast = rng.Chance( _c.FastChance ); // TEMPO BIAS
int bpm = _fast
? _c.FastBpmMin + rng.Int( Math.Max( 1, _c.FastBpmMax - _c.FastBpmMin + 1 ) )
: _c.BpmMin + rng.Int( Math.Max( 1, _c.BpmMax - _c.BpmMin + 1 ) );
_scale = rng.Pick( ScalesFor( _genre ) );
_prog = rng.Pick( ProgressionsFor( _genre ) );
_rootMidi = 28 + rng.Int( 8 ); // E1..B1 bass root
_lead = Instrument.Trumpet; // ska lead is fixed; other genres use guitar
_leadPan = (rng.Next() * 2f - 1f) * _c.PanAmount;
_bassPat = rng.Pick( BassPatternsFor( _genre ) );
_drumStyle = _genre switch // 0 ska rolls a style; the rest are fixed
{
1 => 2, // rock: straight backbeat
2 => 2, // country: train-beat backbeat
3 => 3, // metal: double-kick
_ => _fast ? 2 : rng.Int( 2 ),
};
_organBubble = true;
_hasHorns = true;
_hornMask = new bool[EighthsPerBar];
_hornMask[0] = true;
for ( int e = 1; e < EighthsPerBar; e++ )
_hornMask[e] = rng.Chance( _c.HornDensity * (e % 2 == 1 ? 1.3f : 0.5f) );
// Some songs ride a ride cymbal instead of closed hats for the main pulse (more common
// in rock). Drawn last so it can't shift any earlier musical choice.
_ride = rng.Chance( _genre switch { 1 => 0.5f, 3 => 0.6f, 2 => 0.2f, _ => 0.3f } );
// This song's backbeat kick personality — which off-beat eighths the kick leans into
// beyond the fixed beat-1 & 3 anchors. Only the straight backbeat (rock/country/fast
// ska) reads it; drawn after _ride so it shifts no earlier choice and leaves the other
// styles' songs byte-identical.
_kickAccents = rng.Pick( BackbeatKickAccents );
float swing = _fast ? _c.FastSwing : _c.Swing;
double secPerEighth = 60.0 / bpm / 2.0;
int spe = (int)Math.Round( _sr * secPerEighth );
// Drum tone (toms↔cymbals) → per-voice gain split, and drive (pull↔push) → a constant
// kit timing bias (− = ahead/push, + = behind/lay back; 0.5 = dead on).
float dt = Math.Clamp( _c.DrumTone, 0f, 1f );
_drumTone = dt;
// Gentle gain lean (neutral at 0.5 so the balanced kit is untouched there); the
// bulk of the toms↔cymbals bias now comes from what the part actually plays.
_drumLowMul = 1.2f - 0.4f * dt;
_drumHighMul = 0.7f + 0.6f * dt;
_drumPush = (int)Math.Round( (0.5f - Math.Clamp( _c.DrumDrive, 0f, 1f )) * 2f * 0.13f * spe );
// Lay out the structure and size the buffers to its total length.
var structure = BuildStructure();
int totalBars = 0;
foreach ( var p in structure ) totalBars += p.Bars;
int total = spe * EighthsPerBar * totalBars;
_bufL = new float[total];
_bufR = new float[total];
int barCursor = 0;
for ( int si = 0; si < structure.Count; si++ )
{
var part = structure[si];
RenderSection( part, si, barCursor * EighthsPerBar * spe, spe, secPerEighth, swing );
barCursor += part.Bars;
}
}
// Render one section. Each voice gets its own per-section RNG stream keyed so that repeats
// of a section type reproduce identical backing, while the lead key folds in the verse
// index (so the Nth verse's lead differs) and the fill key folds in the absolute section
// index (so every section closes with a unique fill).
void RenderSection( Part part, int absIndex, int sectionStart, int spe, double secPerEighth, float swing )
{
string bk = SectionKey( part.Type );
string lk = part.Type == Section.Verse ? $"verse:{part.VerseIndex}" : bk;
var bassRng = new Rng( Xmur3( $"{_tag}:bass:{bk}" ) );
var bassOrn = new Rng( Xmur3( $"{_tag}:bassorn:{bk}" ) );
var rhythmRng = new Rng( Xmur3( $"{_tag}:rhythm:{bk}" ) );
var keysRng = new Rng( Xmur3( $"{_tag}:keys:{bk}" ) );
var hornRng = new Rng( Xmur3( $"{_tag}:horn:{bk}" ) );
var leadRng = new Rng( Xmur3( $"{_tag}:lead:{lk}" ) );
// Expression (vibrato/bend/glide/scoop) rolls off their own stream so adding them
// leaves every voice's existing note CHOICES untouched — only pitch-shaping is layered on.
var exprRng = new Rng( Xmur3( $"{_tag}:expr:{lk}" ) );
var noise = new Rng( Xmur3( $"{_tag}:drums:{bk}" ) );
var fillRng = new Rng( Xmur3( $"{_tag}:fill:{absIndex}" ) );
var fillNoise = new Rng( Xmur3( $"{_tag}:fillnoise:{absIndex}" ) );
for ( int bar = 0; bar < part.Bars; bar++ )
{
int chord = (bar / 2) % _prog.Length;
int nextChord = ((bar / 2) + 1) % _prog.Length;
int barStart = sectionStart + bar * EighthsPerBar * spe;
bool lastBar = bar == part.Bars - 1; // every section ends with a fill
RenderBassBar( barStart, spe, secPerEighth, chord, nextChord, bassRng, bassOrn, exprRng );
switch ( _genre )
{
case 1: // rock: keys comp + power-chord guitar
case 2: // country: honky-tonk piano comp + strummed twang guitar
RenderKeysBar( barStart, spe, secPerEighth, chord, keysRng, exprRng );
RenderRhythmGuitarBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
break;
case 3: // metal: palm-muted gallop riff carries the bar
RenderMetalRiffBar( barStart, spe, secPerEighth, chord, rhythmRng, exprRng );
break;
default: // ska: skank chop + horn stabs
RenderRhythmBar( barStart, spe, secPerEighth, chord, swing, rhythmRng, exprRng );
if ( _hasHorns )
RenderHornStabs( barStart, spe, secPerEighth, chord, hornRng, exprRng );
break;
}
RenderDrumBar( barStart, spe, lastBar, noise, fillRng, fillNoise );
if ( bar % 2 == 0 )
RenderLeadPhrase( barStart, spe, secPerEighth, chord, leadRng, exprRng );
}
}
// Master: gentle soft-clip + normalize. The mix peak is first normalized to 1.0
// BEFORE the soft-clipper so it always has headroom — otherwise a hot sustained
// bed (all voices flat at 1.0) saturated the tanh and swallowed the drum
// transients (kick/snare washed out). MasterDrive now sets how hard a
// peak-normalized signal hits the clipper, so the dynamics stay intact.
// Call only after every RenderPitchedRange window has completed.
float Master()
{
int total = _bufL.Length;
float rawPeak = 0f;
for ( int i = 0; i < total; i++ )
rawPeak = Math.Max( rawPeak, Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) ) );
float pre = rawPeak > 0.0001f ? _c.MasterDrive / rawPeak : _c.MasterDrive;
for ( int i = 0; i < total; i++ )
{
_bufL[i] = (float)Math.Tanh( _bufL[i] * pre );
_bufR[i] = (float)Math.Tanh( _bufR[i] * pre );
}
// A touch of stereo room reverb — the dry mix alone read flat/"16-bit".
ApplyReverb();
float peak = 0f;
for ( int i = 0; i < total; i++ )
{
float a = Math.Max( MathF.Abs( _bufL[i] ), MathF.Abs( _bufR[i] ) );
if ( a > peak ) peak = a;
}
return peak > 0.0001f ? _c.MasterPeak / peak : 1f;
}
// ── Master reverb ──
// A Schroeder/Freeverb-style bank: several parallel damped comb filters (the dense
// tail) feeding a chain of allpasses (diffusion). The two channels use slightly
// different delay lengths so the room is decorrelated → real stereo width and depth.
static readonly int[] CombBase = { 1116, 1188, 1277, 1356, 1422, 1491 }; // samples @ 44.1k
static readonly int[] ApBase = { 556, 441, 341 };
const int ReverbStereoSpread = 23; // R-channel delay offset for decorrelation
void ApplyReverb()
{
float wet = Math.Clamp( _c.MasterReverb, 0f, 1f );
if ( wet <= 0.0001f ) return;
float feedback = 0.70f + 0.28f * Math.Clamp( _c.ReverbDecay, 0f, 1f ); // tail length
const float damp = 0.25f, damp1 = 1f - damp; // HF damping in the tail
const float apg = 0.5f; // allpass coefficient
const float inGain = 0.25f; // drive into the reverb
double srk = _sr / 44100.0; // scale delays to the rate
for ( int ch = 0; ch < 2; ch++ )
{
var buf = ch == 0 ? _bufL : _bufR;
int off = ch == 0 ? 0 : ReverbStereoSpread;
int nc = CombBase.Length, na = ApBase.Length;
var combBuf = new float[nc][];
var combIdx = new int[nc];
var combStore = new float[nc];
for ( int j = 0; j < nc; j++ )
combBuf[j] = new float[Math.Max( 1, (int)Math.Round( (CombBase[j] + off) * srk ) )];
var apBuf = new float[na][];
var apIdx = new int[na];
for ( int j = 0; j < na; j++ )
apBuf[j] = new float[Math.Max( 1, (int)Math.Round( (ApBase[j] + off) * srk ) )];
int n = buf.Length;
for ( int i = 0; i < n; i++ )
{
float input = buf[i] * inGain;
float acc = 0f;
for ( int j = 0; j < nc; j++ )
{
var cb = combBuf[j];
int idx = combIdx[j];
float r = cb[idx];
combStore[j] = r * damp1 + combStore[j] * damp;
cb[idx] = input + combStore[j] * feedback;
if ( ++idx >= cb.Length ) idx = 0;
combIdx[j] = idx;
acc += r;
}
acc /= nc;
for ( int j = 0; j < na; j++ )
{
var ab = apBuf[j];
int idx = apIdx[j];
float r = ab[idx];
float o = r - acc;
ab[idx] = acc + r * apg;
if ( ++idx >= ab.Length ) idx = 0;
apIdx[j] = idx;
acc = o;
}
buf[i] += wet * acc;
}
}
}
int ScaleMidi( int baseMidi, int degree )
{
int len = _scale.Length;
int oct = (int)Math.Floor( degree / (double)len );
return baseMidi + _scale[degree - oct * len] + 12 * oct;
}
int ChordRoot( int c ) => ScaleMidi( _rootMidi, _prog[c] );
// ── Instrument expression ──
// Four expressive PROPERTIES every pitched voice can lean on (drums are excluded). Each
// instrument gets a genre-specific PROPENSITY for each, "based on what it is" — a brass
// lead sings and scoops, a bass slides, a power-chord guitar stays dead straight. The
// realization is the per-note pitch shaping in RenderEvent (vibrato depth + bend envelope).
// Vib — #1 vibrato depth (a constant lean, no per-note roll)
// BendIn — #2 bend up INTO the note from a step below (per-note chance)
// Glide — #3 portamento from the previous note's pitch (per-note chance)
// Scoop — #4 bend up-and-back within the note (per-note chance)
readonly struct Expression
{
public readonly float Vib, BendIn, Glide, Scoop;
public Expression( float vib, float bendIn, float glide, float scoop )
{ Vib = vib; BendIn = bendIn; Glide = glide; Scoop = scoop; }
}
const int NoPrev = int.MinValue; // "no previous note" sentinel for glide
// The per-instrument propensity table — genre-aware. Leads route here by genre
// ("LEAD" = ska brass, "LEAD GTR" = rock guitar). Rock lead's BENDINESS knob drives its
// bend-in + scoop directly (that's what "bendiness" is). Tune these by ear.
Expression Expr( string voice )
{
switch ( voice )
{
case "BASS": return _genre switch
{
1 => new Expression( 0f, 0f, 0.10f, 0f ), // rock: locked
2 => new Expression( 0f, 0f, 0.12f, 0.03f ), // country: a subtle slide
3 => default, // metal: dead straight, fast
_ => new Expression( 0f, 0f, 0.25f, 0.05f ), // reggae bass slides
};
case "SKANK": return default; // staccato chops — dead straight
case "ORGAN": return new Expression( 0.15f, 0f, 0f, 0f ); // gentle bubble vibrato (only blooms on held notes)
case "LEAD": return new Expression( 0.35f, 0.15f, 0.10f, 0.25f ); // brass sings + scoops
case "HORNS": return new Expression( 0.20f, 0f, 0f, 0.20f ); // section stabs fall/scoop
case "KEYS": return default; // organ comp — locked, no wobble
case "RHYTHM GTR": return default; // power chords — straight
case "LEAD GTR":
{
// Country leans hard into bends (the telecaster twang); rock/metal ride the knob.
float bend = _genre == 2 ? MathF.Max( _c.LeadGtrBend, 0.5f ) : _c.LeadGtrBend;
return new Expression( 0.30f, bend, 0.10f, bend );
}
default: return default;
}
}
// A rolled-per-note voicing: the concrete pitch-shaping a note will get. Vibrato is a
// constant depth (no draw); bend-in/glide/scoop are rolled against their propensities, so
// only voices that lean on them ever pull from the expression stream.
struct Voicing { public float VibDepth, BendSemis, BendTime, ScoopSemis; }
Voicing Roll( in Expression ex, int midi, int prevMidi, Rng rng )
{
var v = new Voicing();
// Vibrato depth is a SMALL pitch fraction (lean 0.5 ≈ ±10 cents) and it's delayed in
// the synth, so notes read locked-on, not seasick. BendTime is in SECONDS — a quick
// slide that resolves and locks, never a fraction of a long held note.
if ( ex.Vib > 0f ) v.VibDepth = 0.003f + 0.006f * ex.Vib;
if ( ex.Glide > 0f && prevMidi != NoPrev && rng.Chance( ex.Glide ) )
{
v.BendSemis = Math.Clamp( (prevMidi - midi) * 0.3f, -2f, 2f ); // lean toward the prev pitch, not all the way
v.BendTime = 0.13f; // ~130 ms portamento
}
else if ( ex.BendIn > 0f && rng.Chance( ex.BendIn ) )
{
v.BendSemis = rng.Chance( 0.5f ) ? -0.3f : -0.55f; // a subtle lean up into pitch
v.BendTime = 0.09f; // ~90 ms bend up into pitch
}
if ( ex.Scoop > 0f && rng.Chance( ex.Scoop ) )
v.ScoopSemis = rng.Chance( 0.5f ) ? 0.15f : 0.3f; // a slight attack hump
return v;
}
// Bake a rolled voicing onto a patch. VibDepth is harmless unless the patch carries a
// vibrato RATE (p.Vibrato) — so a voice the user muted to 0 Hz stays dry — which means a
// voice that wants expression-vibrato must set its own rate in its patch literal.
static void ApplyVoicing( ref Patch p, in Voicing v )
{
if ( v.VibDepth > 0f ) p.VibDepth = v.VibDepth;
p.BendSemis = v.BendSemis; p.BendTime = v.BendTime; p.ScoopSemis = v.ScoopSemis;
}
// ── Bass ──
void RenderBassBar( int barStart, int spe, double secPerEighth, int chord, int nextChord, Rng rng, Rng bassOrn, Rng exprRng )
{
int root = ChordRoot( chord );
var ex = Expr( "BASS" );
int prevMidi = NoPrev;
// Bass has its own ornament knob (BASS TRIPLETS), nudged up a touch by overall
// kit busyness so a busy vibe gets a busier bass.
float ornChance = _c.BassTriplets * 0.5f + _c.DrumBusy * 0.05f;
for ( int e = 0; e < EighthsPerBar; e++ )
{
int off = _bassPat[e];
if ( off == Rest ) continue;
int midi;
if ( off == Approach )
{
int target = ChordRoot( nextChord );
midi = target - (rng.Chance( 0.5f ) ? 1 : 2); // chromatic/step lead-in
}
else
{
midi = root + off;
if ( off == 0 && e > 0 && rng.Chance( _c.OctavePopChance ) ) midi += 12;
}
// note runs until the next onset (legato reggae feel)
int len = 1;
while ( e + len < EighthsPerBar && _bassPat[e + len] == Rest ) len++;
// Chop a standalone (non-sustaining) note into a 16th pair or 16th-note
// triplet so the line reads "long long short short" / "long short long long"
// instead of even eighths. Driven by a dedicated stream, so the main
// composition RNG order — and every existing song — is left unchanged.
var vc = Roll( ex, midi, prevMidi, exprRng );
prevMidi = midi;
if ( off != Approach && len == 1 && bassOrn.Chance( ornChance ) )
{
int n = bassOrn.Chance( 0.65f ) ? 2 : 3; // 16th pair / 16th triplet
int step = spe / n;
int[] moves = { 0, 7, 12 }; // root / fifth / octave
for ( int k = 0; k < n; k++ )
{
int bm = midi + (k == 0 ? 0 : moves[bassOrn.Int( moves.Length )]);
EmitBass( barStart + e * spe + k * step, (int)(step * 0.9f), bm, secPerEighth / n * 0.8, vc );
}
continue;
}
EmitBass( barStart + e * spe, (int)(spe * len * 0.95f), midi, secPerEighth * len * 0.8, vc );
}
}
void EmitBass( int at, int dur, int midi, double decaySec, in Voicing vc )
{
// Triangle body for a round, deep reggae/dub bass (saw alone read as too
// buzzy) — but triangle alone was too subtle, so layer a quieter square
// underneath for presence/definition. The square's odd harmonics give the
// bass its bite; both share the bass low-pass so the tone stays warm.
var body = new Patch
{
Osc = 3, Voices = 2, Detune = _c.Detune * 0.4f,
Amp = _c.BassVol, Attack = 0.004f, Decay = decaySec,
Sustain = 0.55f, Sustained = true,
Cutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,
Drive = _c.BassDrive, Pan = 0f,
};
var sub = new Patch
{
Osc = 2, Voices = 1, Detune = 0f,
Amp = _c.BassVol * 0.4f, Attack = 0.004f, Decay = decaySec,
Sustain = 0.55f, Sustained = true,
Cutoff = _c.BassCutoff, CutEnv = 350f, Reso = 0.9f,
Drive = _c.BassDrive, Pan = 0f,
};
ApplyVoicing( ref body, vc ); ApplyVoicing( ref sub, vc );
RenderPatch( at, dur, Midi( midi ), body );
RenderPatch( at, dur, Midi( midi ), sub );
}
// ── Skank guitar (the signature) + reggae organ bubble — offbeats, centered ──
void RenderRhythmBar( int barStart, int spe, double secPerEighth, int chord, float swing, Rng rng, Rng exprRng )
{
// +24: skank/organ sit an octave above the old register — at +12 (E2..B2) the
// chop was too low/muddy to cut through and read as missing. Organ stays a
// further octave down via the -12 below.
int gBase = _rootMidi + 24;
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 7 };
// Skank is dead straight (default); the organ bubble gets a gentle vibrato depth.
var organVc = Roll( Expr( "ORGAN" ), 0, NoPrev, exprRng );
for ( int e = 1; e < EighthsPerBar; e += 2 ) // offbeats
{
int at = barStart + e * spe + (int)(swing * spe);
// bright, thin, short guitar chop
foreach ( var d in degs )
RenderPatch( at, (int)(spe * Math.Clamp( _c.SkankChop, 0.15f, 1f )), Midi( ScaleMidi( gBase, d ) ), new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune,
Amp = _c.SkankVol / degs.Length, Attack = 0.002f, Decay = 0.10,
Sustain = 0f, Sustained = false,
Cutoff = _c.SkankCutoff, CutEnv = 1500f, Reso = 0.8f,
Highpass = _c.SkankHighpass, Drive = _c.SkankDrive, Pan = 0f,
} );
// reggae organ "bubble": a softer, rounder offbeat under the guitar
if ( _organBubble )
foreach ( var d in degs )
{
var organ = new Patch
{
Osc = 0, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.OrganVol / degs.Length, Attack = 0.004f, Decay = 0.16,
Sustain = 0.3f, Sustained = false,
Cutoff = _c.OrganCutoff, CutEnv = 0f, Reso = 1.0f, Drive = 1.1f, Pan = 0f,
Vibrato = _c.OrganVibrato,
};
ApplyVoicing( ref organ, organVc );
RenderPatch( at, (int)(spe * 0.55f), Midi( ScaleMidi( gBase, d ) - 12 ), organ );
}
}
}
// ── Lead melody (chord-tone locked → consonant) ──
void RenderLeadPhrase( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int slots = EighthsPerBar * 2;
int melBase = _rootMidi + 24;
int[] tones = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4, _prog[chord] + 6 }; // chord tones
int degree = tones[rng.Int( 3 )];
bool guitarLead = _genre != 0; // ska is the only horn lead
float amp = guitarLead ? _c.LeadGtrVol : _c.MelodyVol;
float drive = guitarLead ? _c.LeadGtrDrive : _c.MelodyDrive;
// Rock lead trades fast RUNS for BENDINESS (handled via expression), so its run rate is
// forced to 0; metal shreds (a high floor of runs); ska/country keep the TRIPLETS knob.
float tripChance = _genre switch
{
1 => 0f,
3 => MathF.Max( _c.TripletChance, 0.4f ),
_ => _c.TripletChance,
};
var ex = guitarLead ? Expr( "LEAD GTR" ) : Expr( "LEAD" );
int prevMidi = NoPrev;
int e = 0;
while ( e < slots )
{
if ( rng.Chance( _c.MelodyRestChance ) ) { e++; continue; }
// ornament: a sixteenth pair, or a triplet at one of three rates — a tight
// 16th-triplet (3 in an eighth), an eighth-note triplet (3 in a beat), or a
// wide quarter-note triplet (3 over two beats). Wider spans give the lazy,
// over-the-barline triplet feel, not just the fast run.
if ( rng.Chance( tripChance ) )
{
float r = rng.Next();
int n, spanE; // n notes evenly across spanE eighths
if ( r < 0.25f ) { n = 2; spanE = 1; } // sixteenth pair
else if ( r < 0.50f ) { n = 3; spanE = 1; } // 16th-note triplet
else if ( r < 0.80f ) { n = 3; spanE = 2; } // eighth-note triplet (1 beat)
else { n = 3; spanE = 4; } // quarter-note triplet (2 beats)
if ( e + spanE > slots ) spanE = 1;
int span = spanE * spe;
int step = span / n;
int firstMidi = ScaleMidi( melBase, Math.Clamp( degree - n / 2, _prog[chord] - 3, _prog[chord] + 10 ) );
var runVc = Roll( ex, firstMidi, prevMidi, exprRng );
for ( int k = 0; k < n; k++ )
{
int d2 = Math.Clamp( degree + (k - n / 2), _prog[chord] - 3, _prog[chord] + 10 );
int m2 = ScaleMidi( melBase, d2 );
RenderLeadNote( barStart + e * spe + k * step, (int)(step * 0.9f),
m2, amp, secPerEighth * spanE / (double)n * 0.85, drive, runVc );
prevMidi = m2;
}
e += spanE;
continue;
}
int len = 1 + rng.Int( 3 );
if ( e + len > slots ) len = slots - e;
bool strong = (e % 2) == 0;
if ( strong )
{
// land on a chord tone near the current degree
int best = tones[0], bestD = 999;
foreach ( var t in tones )
{
for ( int oc = -7; oc <= 14; oc += 7 )
{
int cand = t + (oc / 7) * 7; // keep in degree space
int dist = Math.Abs( cand - degree );
if ( dist < bestD ) { bestD = dist; best = cand; }
}
}
degree = best;
}
else
{
int step = rng.Chance( _c.MelodyLeapChance ) ? (rng.Chance( 0.5f ) ? 3 : -3) : (rng.Chance( 0.5f ) ? 1 : -1);
degree = Math.Clamp( degree + step, _prog[chord] - 3, _prog[chord] + 10 );
}
int midi = ScaleMidi( melBase, degree );
var vc = Roll( ex, midi, prevMidi, exprRng );
RenderLeadNote( barStart + e * spe, (int)(spe * len * 0.9f), midi,
amp, secPerEighth * len * 0.7f, drive, vc );
prevMidi = midi;
e += len;
}
}
// Dispatch a lead note to the genre's lead voice: a distorted single-note guitar for rock,
// otherwise the ska horn (RenderLead → trumpet).
void RenderLeadNote( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )
{
if ( _genre != 0 )
{
// Twang = a bright cutoff-envelope snap on each pick (high CutEnv, decays fast) through
// a resonant SVF, plus a BASE distortion under the slider so it reads as an electric
// guitar even at the slider minimum. The base is genre-set: rock = 3 (overdriven),
// metal = 4 hot (heavy), country = clean (the bite comes from the twang snap + bends,
// not gain). The bends (BENDINESS knob → bend-in + scoop) come in via the voicing.
float driveAmt = _genre switch
{
3 => 4f + MathF.Max( 1f, _c.LeadGtrDrive ), // metal: heavy
2 => 0.8f + 0.3f * MathF.Max( 1f, _c.LeadGtrDrive ),// country: clean twang
_ => 3f + MathF.Max( 1f, _c.LeadGtrDrive ), // rock
};
float cutEnv = _genre == 2 ? 3000f : 2200f; // country: extra twang snap
var gtr = new Patch
{
Osc = 1, Voices = 1, Detune = 0f, Amp = amp,
Attack = 0.002f, Decay = decaySec, Sustain = 0.55f, Sustained = true,
Cutoff = _c.LeadGtrCutoff, CutEnv = cutEnv, Reso = 0.65f,
Drive = driveAmt, Pan = _leadPan, Vibrato = _c.MelodyVibrato,
};
ApplyVoicing( ref gtr, vc );
RenderPatch( at, dur, Midi( midi ), gtr );
return;
}
RenderLead( at, dur, midi, amp, decaySec, drive, vc );
}
// ── Rock KEYS — their OWN part, not a double of the guitar. A syncopated organ comp (the
// "1, &-of-2, 3, &-of-4" Charleston push) playing diatonic TRIADS (root/3rd/5th) in a high
// keyboard register. Different notes (a real triad vs the guitar's bare power chord) AND a
// different rhythm (a 4-hit syncopation vs the guitar's every-eighth chug), so the two
// interlock instead of playing in lockstep. KeysChug rings the chords (0) or tightens them
// toward short stabs (1).
static readonly int[] KeysOnsets = { 0, 3, 4, 7 }; // eighth positions of the comp's hits
void RenderKeysBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int kBase = _rootMidi + 24; // keyboard register, an octave over the rhythm guitar
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 }; // diatonic triad
float chug = Math.Clamp( _c.KeysChug, 0f, 1f );
// Country reads this comp as a honky-tonk piano: keep it clean (rock drives it dirty).
float keysDrive = _genre == 2 ? 1f + 0.2f * MathF.Max( 1f, _c.KeysDrive )
: MathF.Max( 1f, _c.KeysDrive );
var keysVc = Roll( Expr( "KEYS" ), 0, NoPrev, exprRng ); // gentle vibrato only
for ( int oi = 0; oi < KeysOnsets.Length; oi++ )
{
int e = KeysOnsets[oi];
int nextE = oi + 1 < KeysOnsets.Length ? KeysOnsets[oi + 1] : EighthsPerBar; // ring up to the next hit
int gap = nextE - e;
bool ring = chug < 0.5f;
int dur = (int)(gap * spe * Math.Max( 0.25f, 1f - 0.7f * chug ));
double dec = secPerEighth * gap * (ring ? 0.9 : 0.4);
foreach ( var d in degs )
{
var keys = new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.KeysVol / degs.Length,
Attack = 0.004f, Decay = dec, Sustain = ring ? 0.6f : 0.2f, Sustained = ring,
Cutoff = _c.KeysCutoff, CutEnv = 250f, Reso = 1.0f,
Drive = keysDrive, Pan = 0f,
};
ApplyVoicing( ref keys, keysVc );
RenderPatch( barStart + e * spe, dur, Midi( ScaleMidi( kBase, d ) ), keys );
}
}
}
// ── Rock rhythm guitar — twangy distorted power chords. Shares the lead guitar's voice (the
// bright cutoff-envelope "twang" through a resonant SVF) but strums root+fifth+octave and
// runs a LOWER base distortion than the lead so the two layer instead of mush. Downbeats
// ring; offbeats tighten toward a palm-muted chug as RhythmGtrChug rises.
// exprRng is unused: power chords stay dead straight (Expr("RHYTHM GTR") is default), but the
// param keeps every instrument's call site uniform.
void RenderRhythmGuitarBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
bool country = _genre == 2;
int root = ChordRoot( chord ) + 12; // chunky power-chord register
// Country strums a full open triad (root/3rd/5th/octave) clean and bright; rock chunks a
// bare power chord (root/5th/octave) with more base distortion.
int[] chordOffs = country ? new[] { 0, 4, 7, 12 } : new[] { 0, 7, 12 };
float chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );
float cutEnv = country ? 2600f : 1400f; // brighter twang for the clean strum
float driveAmt = country ? 0.8f + 0.3f * MathF.Max( 1f, _c.RhythmGtrDrive )
: 1.5f + MathF.Max( 1f, _c.RhythmGtrDrive ); // less base than lead
for ( int e = 0; e < EighthsPerBar; e++ )
{
bool accent = (e % 2) == 0; // downbeats ring, offbeats chug
float lenFrac = accent ? (1f - 0.5f * chug) : (0.35f - 0.2f * chug);
int dur = (int)(spe * Math.Max( 0.12f, lenFrac ));
double dec = secPerEighth * (accent ? 0.8 : 0.3);
foreach ( var o in chordOffs )
RenderPatch( barStart + e * spe, dur, Midi( root + o ), new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.RhythmGtrVol / chordOffs.Length * (accent ? 1f : 0.7f),
Attack = 0.002f, Decay = dec, Sustain = accent ? 0.45f : 0f, Sustained = accent,
Cutoff = _c.RhythmGtrCutoff, CutEnv = cutEnv, Reso = 0.8f, // twang
Drive = driveAmt, Pan = 0f,
} );
}
}
// ── Metal rhythm guitar — palm-muted 16th-note gallop on the low root with power-chord
// accents. The relentless 16th chug (under the double-kick) is the "fast riff" engine; the
// downbeats and a few syncopated stabs ring a full power chord. Heavy base distortion, dark
// and tight. rng (the rhythm stream) breaks up the accent placement so riffs vary by section.
void RenderMetalRiffBar( int barStart, int spe, double secPerEighth, int chord, Rng rng, Rng exprRng )
{
int root = ChordRoot( chord ); // low, chunky — no octave bump
int[] power = { 0, 7, 12 };
int six = spe / 2;
if ( six <= 0 ) return;
float chug = Math.Clamp( _c.RhythmGtrChug, 0f, 1f );
float driveAmt = 4f + MathF.Max( 1f, _c.RhythmGtrDrive ); // heavy
for ( int s = 0; s < EighthsPerBar * 2; s++ ) // 16 sixteenths
{
int at = barStart + s * six;
bool beat = s % 4 == 0; // quarter-note downbeats → ring a chord
bool ring = beat || (s % 2 == 0 && rng.Chance( 0.3f )); // some offbeat eighths ring too
int[] offs = ring ? power : new[] { 0 }; // accents = power chord, chugs = root only
float gain = ring ? 1f : 0.6f;
// Palm mute = short, tight; accents ring longer. Chug tightens the muted notes further.
int dur = (int)(six * (ring ? 0.9f : Math.Max( 0.25f, 0.55f - 0.3f * chug )));
double dec = secPerEighth * (ring ? 0.4 : 0.12);
foreach ( var o in offs )
RenderPatch( at, dur, Midi( root + o ), new Patch
{
Osc = 1, Voices = 2, Detune = _c.Detune * 0.5f,
Amp = _c.RhythmGtrVol / offs.Length * gain,
Attack = 0.002f, Decay = dec, Sustain = ring ? 0.35f : 0f, Sustained = ring,
Cutoff = _c.RhythmGtrCutoff, CutEnv = 1100f, Reso = 0.7f,
Drive = driveAmt, Pan = 0f,
} );
}
}
// ── Backing horns (panned spread) ──
// Block stabs on the mask read "samey" (only eighth-note chords). A dedicated
// stream (horn:tag, so the main composition order is unchanged) breaks them up
// with rolling arpeggios, 16th pairs, grace pickups and varied length. Kept
// modest — the bass got over-busy when its ornament rate ran high.
void RenderHornStabs( int barStart, int spe, double secPerEighth, int chord, Rng orn, Rng exprRng )
{
int baseMidi = _rootMidi + 19;
int[] degs = { _prog[chord], _prog[chord] + 2, _prog[chord] + 4 };
float spread = _c.PanAmount * 0.7f;
int six = spe / 2;
float ornChance = 0.18f + _c.TripletChance; // ~0.24 default; rides the same knob
// Section expression — vibrato + a chance of a scoop/fall, rolled once per onset (below)
// and applied to every tone of that stab so the whole section bends together.
var ex = Expr( "HORNS" );
Voicing hornVc = default;
// one chord-tone voice
void Note( int at, int dur, int k, double dec, float gain )
{
var horn = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune,
Amp = _c.HornVol / degs.Length * gain, Attack = 0.008f, Decay = dec,
Sustain = 0.2f, Sustained = false,
Cutoff = _c.HornCutoff, CutEnv = 1200f, Reso = 1.0f,
Drive = _c.HornDrive, Pan = spread * (k / (float)(degs.Length - 1) * 2f - 1f),
Vibrato = _c.MelodyVibrato,
};
ApplyVoicing( ref horn, hornVc );
RenderPatch( at, dur, Midi( ScaleMidi( baseMidi, degs[k] ) ), horn );
}
// full block chord stab
void Stab( int at, int dur, double dec, float gain )
{
for ( int k = 0; k < degs.Length; k++ ) Note( at, dur, k, dec, gain );
}
for ( int e = 0; e < EighthsPerBar; e++ )
{
if ( !_hornMask[e] ) continue;
int at = barStart + e * spe;
hornVc = Roll( ex, baseMidi, NoPrev, exprRng );
if ( six > 0 && orn.Chance( ornChance ) )
{
float r = orn.Next();
if ( r < 0.4f )
{
// rolling arpeggio: chord tones climb across a 16th-triplet
int step = spe / 3;
for ( int k = 0; k < degs.Length; k++ )
Note( at + k * step, (int)(step * 0.9f), k, secPerEighth / 3 * 0.8, 1f );
continue;
}
if ( r < 0.75f )
{
// 16th pair: stab on the beat, softer echo on the "e"
Stab( at, (int)(six * 0.85f), secPerEighth * 0.5 * 0.8, 1f );
Stab( at + six, (int)(six * 0.85f), secPerEighth * 0.5 * 0.7, 0.6f );
continue;
}
// grace pickup: a soft single tone just before the block stab
Note( at - six, (int)(six * 0.8f), 0, secPerEighth * 0.5 * 0.6, 0.5f );
Stab( at, (int)(spe * 0.6f), 0.22, 1f );
continue;
}
// plain stab — length varies a touch so even straight bars aren't identical
float lenMul = 0.45f + orn.Next() * 0.35f;
Stab( at, (int)(spe * lenMul), 0.22, 1f );
}
}
// ── Lead instrument voices ──
enum Instrument { Trumpet, Sax, Organ, Trombone }
Instrument PickInstrument( Rng rng )
{
if ( _c.ForceInstrument >= 0 && _c.ForceInstrument <= 3 ) return (Instrument)_c.ForceInstrument;
float tw = MathF.Max( 0f, _c.TrumpetWeight ), sw = MathF.Max( 0f, _c.SaxWeight );
float ow = MathF.Max( 0f, _c.OrganWeight ), bw = MathF.Max( 0f, _c.TromboneWeight );
float sum = tw + sw + ow + bw;
if ( sum <= 0f ) return Instrument.Trumpet;
float r = rng.Next() * sum;
if ( (r -= tw) < 0f ) return Instrument.Trumpet;
if ( (r -= sw) < 0f ) return Instrument.Sax;
if ( (r -= ow) < 0f ) return Instrument.Organ;
return Instrument.Trombone;
}
void RenderLead( int at, int dur, int midi, float amp, double decaySec, float drive, in Voicing vc )
{
Patch p; int m = midi;
switch ( _lead )
{
case Instrument.Trumpet:
p = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp,
Attack = 0.01f, Decay = decaySec, Sustain = 0.7f, Sustained = true,
Cutoff = _c.LeadCutoff, CutEnv = 1800f, Reso = 1.0f, Drive = drive,
Pan = _leadPan, Vibrato = _c.MelodyVibrato,
};
break;
case Instrument.Trombone:
m = midi - 12;
p = new Patch
{
Osc = 1, Voices = 3, Detune = _c.Detune * 0.7f, Amp = amp * 1.1f,
Attack = 0.02f, Decay = decaySec, Sustain = 0.7f, Sustained = true,
Cutoff = _c.LeadCutoff * 0.7f, CutEnv = 900f, Reso = 1.0f, Drive = MathF.Max( 1f, drive * 0.8f ),
Pan = _leadPan, Vibrato = _c.MelodyVibrato * 0.7f,
};
break;
case Instrument.Sax:
p = new Patch
{
Osc = 3, Voices = 2, Detune = _c.Detune * 0.5f, Amp = amp * 1.15f,
Attack = 0.014f, Decay = decaySec, Sustain = 0.75f, Sustained = true,
Cutoff = _c.LeadCutoff, CutEnv = 1400f, Reso = 0.7f, Drive = MathF.Max( 1.2f, drive ),
Pan = _leadPan, Vibrato = _c.MelodyVibrato, Breath = 0.03f,
};
break;
default: // Organ
p = new Patch
{
Osc = 0, Voices = 3, Detune = _c.Detune * 0.6f, Amp = amp,
Attack = 0.006f, Decay = decaySec * 1.5, Sustain = 0.9f, Sustained = true,
Cutoff = 2600f, CutEnv = 0f, Reso = 1.0f, Drive = 1.15f,
Pan = _leadPan, Vibrato = _c.MelodyVibrato * 0.9f,
};
break;
}
ApplyVoicing( ref p, vc );
RenderPatch( at, dur, Midi( m ), p );
}
// ── Synth core: unison osc → optional high-pass → resonant low-pass (cutoff
// envelope) → soft drive → AD/sustain amp env. ──
struct Patch
{
public int Osc; // 0 sine 1 saw 2 square 3 triangle
public int Voices;
public float Detune; // cents
public float Amp;
public float Attack; // sec
public double Decay; // sec (exp time constant)
public float Sustain; // 0..1 (only if Sustained)
public bool Sustained;
public float Cutoff; // Hz low-pass
public float CutEnv; // Hz added at attack, decays with Decay
public float Reso; // SVF damping (lower = more resonance)
public float Highpass; // Hz one-pole high-pass (0 = off)
public float Drive; // tanh
public float Pan; // -1..1
public float Vibrato; // Hz (rate of the pitch wobble)
public float Breath; // 0..1 noise mix (reeds)
// ── Expression (per-note pitch shaping; see Expression/Voicing) ──
public float VibDepth; // vibrato depth as a pitch fraction (0 → legacy 0.005 when Vibrato>0)
public float BendSemis; // pitch offset in semitones at note START, glides to 0 (bend-in / glide); −ve starts below
public float BendTime; // 0..1 fraction of the note over which BendSemis glides to 0
public float ScoopSemis; // height (semitones) of a mid-note bend-up-and-back hump (0 = none)
}
// Pitched note events collected during ComposePlan, then synthesized by
// RenderPitchedRange. Synthesis pulls no RNG, so windows parallelize across threads.
struct NoteEvent { public int Start, Dur; public float Freq; public Patch P; }
readonly List<NoteEvent> _events = new();
// During ComposePlan this only enqueues; the synthesis happens in RenderPitchedRange.
void RenderPatch( int start, int dur, float freq, Patch p )
{
if ( start < 0 || dur <= 0 || p.Voices < 1 ) return;
_events.Add( new NoteEvent { Start = start, Dur = dur, Freq = freq, P = p } );
}
/// <summary>Synthesize every pitched event whose span overlaps <c>[from, to)</c>,
/// writing ONLY samples inside that window. Safe to call concurrently for disjoint
/// windows: each output index is owned by exactly one window, a boundary-spanning
/// note is re-rendered from its own start by each window (the SVF / high-pass state
/// can't be resumed mid-stream), and each window walks <c>_events</c> in order, so
/// writes never collide and the per-index sum order is deterministic.</summary>
public void RenderPitchedRange( int from, int to )
{
from = Math.Max( 0, from );
to = Math.Min( _bufL.Length, to );
if ( to <= from ) return;
var ph = new double[8];
var inc = new double[8];
var events = _events;
for ( int k = 0; k < events.Count; k++ )
{
var ev = events[k];
int end = Math.Min( _bufL.Length, ev.Start + ev.Dur );
if ( end <= from || ev.Start >= to ) continue; // no overlap with this window
RenderEvent( ev, from, to, ph, inc );
}
}
// One pitched note. Computes from the note's own start (the running filter / breath
// state can't be resumed mid-note) but writes only within [clipFrom, clipTo), and
// stops once past clipTo since later windows own those samples. ph/inc are caller-
// owned scratch (per-thread → no shared state).
void RenderEvent( in NoteEvent ev, int clipFrom, int clipTo, double[] ph, double[] inc )
{
int start = ev.Start, dur = ev.Dur;
float freq = ev.Freq;
var p = ev.P;
StereoGains( p.Pan, out float gL, out float gR );
int atk = Math.Max( 1, (int)(p.Attack * _sr) );
double decSamp = Math.Max( 1.0, p.Decay * _sr );
int rel = Math.Max( 1, (int)(0.006f * _sr) );
int voices = Math.Min( 8, p.Voices );
for ( int v = 0; v < voices; v++ )
{
ph[v] = 0;
float cents = voices == 1 ? 0f : (v - (voices - 1) * 0.5f) * p.Detune;
inc[v] = freq * Math.Pow( 2, cents / 1200.0 ) / _sr;
}
float low = 0, band = 0;
float reso = Math.Clamp( p.Reso, 0.2f, 2f );
float dnorm = p.Drive > 1f ? 1f / (float)Math.Tanh( p.Drive ) : 1f;
float hpA = p.Highpass > 0f ? (float)(1.0 / (1.0 + 2 * Math.PI * p.Highpass / _sr)) : 0f;
float hpInPrev = 0f, hpOutPrev = 0f;
uint bn = 0x9E3779B9u;
int end = Math.Min( Math.Min( _bufL.Length, start + dur ), clipTo );
int relStart = dur - rel;
// Expression windows (samples): vibrato holds off then ramps in; the scoop is a quick
// attack gesture. Kept fixed/absolute so a long held note locks on pitch after them.
int vibDelay = (int)(0.18f * _sr);
int vibRamp = Math.Max( 1, (int)(0.16f * _sr) );
int scoopWin = Math.Max( 1, (int)(0.16f * _sr) );
for ( int i = 0; start + i < end; i++ )
{
float env;
if ( i < atk ) env = (float)i / atk;
else if ( p.Sustained )
{
float d = (float)Math.Exp( -(i - atk) / decSamp );
env = p.Sustain + (1f - p.Sustain) * d;
}
else env = (float)Math.Exp( -(i - atk) / decSamp );
if ( i >= relStart ) env *= Math.Max( 0f, (float)(dur - i) / rel );
if ( env < 0.0006f && i > atk && !p.Sustained ) break;
float s = 0f;
// Vibrato: subtle, and DELAYED so the note locks on pitch first and only blooms a
// wobble if it's held — short notes stay dead-on. Depth is a small pitch fraction.
float vib = 1f;
if ( p.Vibrato > 0f && p.VibDepth > 0f )
{
float ramp = MathF.Max( 0f, (i - vibDelay) / (float)vibRamp );
if ( ramp > 1f ) ramp = 1f;
if ( ramp > 0f )
vib = (float)(1.0 + p.VibDepth * ramp * Math.Sin( i / (double)_sr * p.Vibrato * 2 * Math.PI ));
}
// Pitch-bend envelope (semitones) on top of vibrato. Both are QUICK gestures over a
// short fixed window so the note then sits locked on its target pitch (bendMul == 1):
// BendSemis snaps to 0 over BendTime seconds (bend-in / glide); ScoopSemis is a fast
// up-and-back hump confined to the attack (bend-and-release).
float bendSemis = 0f;
if ( p.BendSemis != 0f && p.BendTime > 0f )
{
int bt = Math.Min( dur, Math.Max( 1, (int)(p.BendTime * _sr) ) );
if ( i < bt ) { float u = i / (float)bt; bendSemis += p.BendSemis * (1f - u * u * (3f - 2f * u)); }
}
if ( p.ScoopSemis != 0f && i < scoopWin )
bendSemis += p.ScoopSemis * MathF.Sin( (float)(i / (float)Math.Min( dur, scoopWin ) * Math.PI) );
float bendMul = bendSemis != 0f ? (float)Math.Pow( 2.0, bendSemis / 12.0 ) : 1f;
for ( int v = 0; v < voices; v++ )
{
double dt = inc[v] * vib * bendMul;
s += BlepOsc( p.Osc, ph[v] - Math.Floor( ph[v] ), dt );
ph[v] += dt;
}
s /= voices;
if ( p.Breath > 0f )
{
bn = unchecked( bn * 1664525u + 1013904223u );
s += (bn / 4294967296f * 2f - 1f) * p.Breath;
}
if ( hpA > 0f )
{
float hp = hpA * (hpOutPrev + s - hpInPrev);
hpInPrev = s; hpOutPrev = hp; s = hp;
}
// resonant low-pass (Chamberlin SVF) with cutoff envelope.
// Clamp to ~sr/6 to keep the SVF stable.
float cut = p.Cutoff + (p.CutEnv > 0f ? p.CutEnv * (float)Math.Exp( -i / decSamp ) : 0f);
float f = (float)(2 * Math.Sin( Math.PI * Math.Min( cut, _sr * 0.16f ) / _sr ));
float high = s - low - reso * band;
band += f * high;
low += f * band;
float outp = low;
if ( p.Drive > 1f ) outp = (float)Math.Tanh( outp * p.Drive ) * dnorm;
float val = outp * env * p.Amp;
int idx = start + i;
if ( idx >= clipFrom )
{
_bufL[idx] += val * gL;
_bufR[idx] += val * gR;
}
}
}
// Band-limited oscillator. Naive saw/square step instantaneously at the phase wrap,
// and those discontinuities alias into harsh inharmonic tones — the core of the
// "8-/16-bit" buzz. PolyBLEP rounds each discontinuity over one sample so the harmonics
// fold back cleanly, for a warm analog edge instead. Sine is already band-limited;
// triangle's corners roll off as 1/n² so its aliasing is inaudible.
// p = phase in [0,1), dt = phase increment per sample (cycles/sample).
static float BlepOsc( int t, double p, double dt )
{
switch ( t )
{
case 0:
return MathF.Sin( (float)(p * 2 * Math.PI) );
case 1: // saw
return (float)(2 * p - 1) - PolyBlep( p, dt );
case 2: // square (50% duty = two opposed discontinuities)
{
float v = p < 0.5 ? 1f : -1f;
v += PolyBlep( p, dt );
double p2 = p + 0.5; if ( p2 >= 1.0 ) p2 -= 1.0;
return v - PolyBlep( p2, dt );
}
default: // triangle
return 4f * MathF.Abs( (float)p - 0.5f ) - 1f;
}
}
// PolyBLEP residual: the correction applied around a step discontinuity.
static float PolyBlep( double t, double dt )
{
if ( dt <= 0 ) return 0f;
if ( t < dt ) { t /= dt; return (float)(t + t - t * t - 1.0); }
if ( t > 1.0 - dt ) { t = (t - 1.0) / dt; return (float)(t * t + t + t + 1.0); }
return 0f;
}
static float Midi( int m ) => 440f * MathF.Pow( 2f, (m - 69) / 12f );
const float Sqrt2 = 1.41421356f;
static void StereoGains( float pan, out float gL, out float gR )
{
pan = Math.Clamp( pan, -1f, 1f );
double ang = (pan + 1) * 0.5 * (Math.PI / 2);
gL = (float)Math.Cos( ang ) * Sqrt2;
gR = (float)Math.Sin( ang ) * Sqrt2;
}
// ── Drums ──
// Render a bar of kit. On a section's last bar (fillEnd) the closing beat is replaced by a
// fill — driven by its own RNG streams so every section's fill is different even when the
// groove before it is identical.
void RenderDrumBar( int barStart, int spe, bool fillEnd, Rng noise, Rng fillRng, Rng fillNoise )
{
// Knob ceiling was too frantic: scale so DRUM BUSY 100% reads as the old 75%.
float busy = Math.Clamp( _c.DrumBusy, 0f, 1f ) * 0.75f;
int six = spe / 2;
int hatEnd = fillEnd ? 6 : EighthsPerBar; // hats stop where the fill begins
// closed hats on eighths (open on the "and of 4"); busy fills the gaps with
// quieter sixteenth-note hats (constant 16th chatter at the top of the range). On
// ride songs the ride cymbal carries the eighth pulse instead (bell on the beats), with
// the open hat still punctuating the "and of 4".
for ( int e = 0; e < hatEnd; e++ )
{
int at = barStart + e * spe;
bool open = e == 7;
float amp = e % 2 == 1 ? _c.HatVol : _c.HatVol * 0.6f;
if ( _ride && !open )
RenderRide( at, e % 2 == 0, amp, noise ); // bell accent on the downbeats
else
RenderHat( at, open, amp, noise );
if ( !open && six > 0 && noise.Chance( busy ) )
{
if ( _ride ) RenderRide( at + six, false, _c.HatVol * 0.4f, noise );
else RenderHat( at + six, false, _c.HatVol * 0.4f, noise );
}
}
if ( fillEnd )
{
RenderKickSnareGroove( barStart, spe, 0, 6, busy, noise ); // first 3 beats normal
RenderFill( barStart + 6 * spe, spe, fillNoise, fillRng );
return;
}
RenderKickSnareGroove( barStart, spe, 0, EighthsPerBar, busy, noise );
}
// Per-song kick accents for the straight backbeat: eighths (beyond the beat-1 & 3 anchors)
// the kick leans into. e1 = "and of 1", e3 = "and of 2", e5 = "and of 3", e6 = beat 4,
// e7 = "and of 4". One set is picked per song, then each accent is rolled per bar so the
// groove breathes instead of stamping the same kick pattern every bar — the main nuance
// lever that keeps rock/country from all sharing one mechanical backbeat.
static readonly int[][] BackbeatKickAccents =
{
new int[0], // bone-dry: just 1 & 3
new[] { 3 }, // push into the snare ("and of 2")
new[] { 7 }, // pickup into the next bar ("and of 4")
new[] { 3, 7 }, // push + pickup
new[] { 6 }, // driving beat-4 kick
new[] { 5, 7 }, // syncopated "and of 3" + pickup
new[] { 3, 6 }, // push into 3 + beat-4 drive
};
void RenderKickSnareGroove( int barStart, int spe, int from, int to, float busy, Rng noise )
{
int six = spe / 2;
for ( int e = from; e < to; e++ )
{
int at = barStart + e * spe;
switch ( _drumStyle )
{
case 0: // one-drop: kick + snare together on beat 3
if ( e == 4 ) { RenderKick( at, noise ); RenderSnare( at, noise, false ); }
else if ( e == 2 && noise.Chance( _c.GhostSnareChance * (0.4f + busy) ) ) RenderSnare( at, noise, true );
break;
case 1: // steppers: kick every beat, snare on 2 & 4
if ( e % 2 == 0 ) RenderKick( at, noise );
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
break;
case 3: // metal double-kick: 16th-note kick gallop + crashing, snare backbeat
if ( e == 0 && noise.Chance( 0.55f ) ) RenderCrash( at, noise, noise.Chance( 0.35f ) );
RenderKick( at, noise );
if ( six > 0 ) RenderKick( at + six, noise ); // the second pedal → the 16th gallop
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
break;
default: // straight backbeat — anchors on beats 1 & 3, plus this song's kick
// accents, each humanised per bar so the groove breathes
bool kick = e == 0 || e == 4;
if ( !kick && Array.IndexOf( _kickAccents, e ) >= 0 )
kick = noise.Chance( 0.82f ); // mostly play the accent, occasionally lay out
else if ( !kick && e == 3 )
kick = noise.Chance( _c.KickSyncChance * (0.4f + busy) ); // stray push into beat 3
if ( kick ) RenderKick( at, noise );
if ( e == 2 || e == 6 ) RenderSnare( at, noise, false );
else if ( noise.Chance( _c.GhostSnareChance * busy ) ) RenderSnare( at, noise, true );
break;
}
// Busy fills the "e/a" sixteenths between hits: more snare as busy rises, and —
// when the tone leans low — toms too. (Busy → snare + toms; tone → tom vs cymbal.)
// Metal already fills every 16th with the double-kick, so it skips the ghost layer.
if ( _drumStyle != 3 && six > 0 && e != 4 && noise.Chance( _c.GhostSnareChance * busy ) )
{
if ( noise.Chance( (1f - _drumTone) * 0.5f ) )
RenderTom( at + six, 110f + 30f * (e & 1), noise );
else
RenderSnare( at + six, noise, true );
}
}
}
// Tom/snare roll across the last beat (two eighths). Straight = four 16ths;
// triplet (TripletChance) = six even subdivisions for a rolling shuffle feel.
void RenderFill( int at, int spe, Rng noise, Rng rng )
{
// straight = four 16ths; triplet = either an eighth-note triplet (3) or a
// faster 16th-note triplet (6) across the beat.
int n = rng.Chance( _c.TripletChance ) ? (rng.Chance( 0.5f ) ? 3 : 6) : 4;
int step = (spe * 2) / n;
float[] toms = { 200f, 165f, 135f, 110f, 90f, 72f };
// Half the hits stay snare so it still reads as a drum fill; the rest are biased by
// DrumTone — toms when the tone leans low, cymbals (ride hits) when it leans high.
for ( int i = 0; i < n; i++ )
{
int t = at + i * step;
if ( rng.Chance( 0.5f ) ) RenderSnare( t, noise, false );
else if ( rng.Chance( _drumTone ) ) RenderRide( t, false, _c.HatVol, noise );
else RenderTom( t, toms[i], noise );
}
// crash into the downbeat (may land at bar end) — a bright crash or a darker, washier
// crash, picked off the fill stream so the cymbal colour varies section to section.
RenderCrash( at + n * step, noise, rng.Chance( 0.4f ) );
}
void RenderKick( int start, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * 0.17f); // a little longer tail for thump (was 0.13)
double decay = dur * 0.31; // slightly slower decay = a touch more boom
double subDecay = dur * 0.55; // sub layer rings longer for weight
double phase = 0, subPhase = 0;
// noise.Next() only fires in the fixed 3ms click below, so changing dur/decay
// here does NOT shift the drum RNG stream (patterns are preserved).
int clickLen = (int)(_sr * 0.003f);
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
phase += (127f - 80f * MathF.Min( 1f, t * 2.6f )) / _sr; // pitch drop 127→47
subPhase += 44f / _sr; // steady sub fundamental
float env = (float)Math.Exp( -i / decay );
float subEnv = (float)Math.Exp( -i / subDecay );
float body = (float)Math.Tanh( MathF.Sin( (float)(phase * 2 * Math.PI) ) * 1.6f ) * env;
float sub = MathF.Sin( (float)(subPhase * 2 * Math.PI) ) * 0.3f * subEnv;
float click = i < clickLen ? (noise.Next() * 2f - 1f) * 0.55f * (1f - i / (float)clickLen) : 0f;
float v = (body + sub + click) * _c.KickVol * KickBalance * _drumGain * _drumLowMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
// One-pole high-pass coefficient (unconditionally stable).
float HpCoeff( float fc ) => (float)(1.0 / (1.0 + 2 * Math.PI * fc / _sr));
void RenderSnare( int start, Rng noise, bool ghost )
{
start = Math.Max( 0, start + _drumPush );
// dur and the single noise.Next()/sample are kept exactly so the drum RNG stream
// is unchanged — only the timbre (more shell body) is rerolled.
int dur = (int)(_sr * (ghost ? 0.06f : 0.15f));
double decay = dur * (ghost ? 0.3 : 0.32);
double phase = 0, phase2 = 0;
float amp = _c.SnareVol * SnareBalance * (ghost ? 0.3f : 1f) * _drumGain;
float a = HpCoeff( 1350f ); // slightly crisper wire crack (was 1200)
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
float env = (float)Math.Exp( -i / decay );
float drop = 1f - 0.14f * t; // shell pitch sags a touch → "dow"
phase += 185f * drop / _sr;
phase2 += 268f * drop / _sr;
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
// two-tone shell body, a bit fuller than before, vs the wire layer
float body = (MathF.Sin( (float)(phase * 2 * Math.PI) ) + MathF.Sin( (float)(phase2 * 2 * Math.PI) ) * 0.6f) * 0.375f;
float v = ((float)Math.Tanh( hp * 1.2f ) * 0.6f + body) * env * amp;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
void RenderTom( int start, float baseFreq, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * 0.18f);
double decay = dur * 0.3;
double phase = 0;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float t = (float)i / dur;
phase += (baseFreq * (1f - 0.35f * t)) / _sr;
float env = (float)Math.Exp( -i / decay );
float v = MathF.Sin( (float)(phase * 2 * Math.PI) ) * env * _c.TomVol * TomBalance * _drumGain * _drumLowMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
void RenderHat( int start, bool open, float amp, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * (open ? 0.16f : 0.035f));
double decay = dur * 0.4;
float a = HpCoeff( 7000f );
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * env * amp * HatBalance * _drumGain * _drumHighMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
// Two crash colours off one voice: the bright crash (short-ish, high-passed high) and a
// dark crash — lower cutoff, longer wash, a touch quieter — for a bigger china/ride-crash.
void RenderCrash( int start, Rng noise, bool dark = false )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * (dark ? 0.9f : 0.6f));
double decay = dur * (dark ? 0.5 : 0.45);
float a = HpCoeff( dark ? 2600f : 4000f );
float amp = _c.CrashVol * CrashBalance * (dark ? 0.85f : 1f);
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * env * amp * _drumGain * _drumHighMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
// Ride cymbal — a sustained, high-passed noise wash, nothing more. A cymbal is mostly
// filtered noise; we deliberately render *only* that body and no pitched component. Earlier
// versions layered inharmonic sine partials for a metallic "ping"/bell, but any tonal layer
// (however quiet or detuned) read as a pitched ring/ding, so it's gone entirely. The bell
// hit (on the beat) just rings a touch longer than the bow hit (on the "and"). Tracks off
// HatVol via the caller's amp so the existing DRUMS knobs still balance it.
void RenderRide( int start, bool bell, float amp, Rng noise )
{
start = Math.Max( 0, start + _drumPush );
int dur = (int)(_sr * (bell ? 0.34f : 0.22f));
double decay = dur * 0.42;
float a = HpCoeff( 7000f );
float inPrev = 0f, outPrev = 0f;
int end = Math.Min( _bufL.Length, start + dur );
for ( int i = 0; start + i < end; i++ )
{
float env = (float)Math.Exp( -i / decay );
float n = noise.Next() * 2f - 1f;
float hp = a * (outPrev + n - inPrev); inPrev = n; outPrev = hp;
float v = hp * 0.7f * env * amp * HatBalance * _drumGain * _drumHighMul;
_bufL[start + i] += v; _bufR[start + i] += v;
}
}
// ── Output ──
short[] ToShorts( float gain )
{
int n = _bufL.Length;
var s = new short[n * Channels];
for ( int i = 0; i < n; i++ )
{
s[i * 2] = ToS16( _bufL[i] * gain );
s[i * 2 + 1] = ToS16( _bufR[i] * gain );
}
return s;
}
static short ToS16( float v ) => (short)(Math.Clamp( v, -1f, 1f ) * 32767f);
/// <summary>Wrap already-rendered 16-bit samples in a WAV (for export). Mono or
/// interleaved stereo per <paramref name="channels"/>.</summary>
public static byte[] WavFromSamples( short[] samples, int channels, int sampleRate )
{
int dataSize = samples.Length * 2;
int blockAlign = channels * 2;
var bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );
void Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }
void U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }
void U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }
Str( "RIFF" ); U32( (uint)(36 + dataSize) ); Str( "WAVE" );
Str( "fmt " ); U32( 16 ); U16( 1 ); U16( (ushort)channels );
U32( (uint)sampleRate ); U32( (uint)(sampleRate * blockAlign) ); U16( (ushort)blockAlign ); U16( 16 );
Str( "data" ); U32( (uint)dataSize );
foreach ( var s in samples ) { ushort u = (ushort)s; bytes.Add( (byte)u ); bytes.Add( (byte)(u >> 8) ); }
return bytes.ToArray();
}
byte[] EncodeWav( float gain )
{
int n = _bufL.Length;
int dataSize = n * 4;
var bytes = new System.Collections.Generic.List<byte>( 44 + dataSize );
void Str( string s ) { foreach ( var ch in s ) bytes.Add( (byte)ch ); }
void U32( uint v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); bytes.Add( (byte)(v >> 16) ); bytes.Add( (byte)(v >> 24) ); }
void U16( ushort v ) { bytes.Add( (byte)v ); bytes.Add( (byte)(v >> 8) ); }
Str( "RIFF" ); U32( (uint)(36 + dataSize) ); Str( "WAVE" );
Str( "fmt " ); U32( 16 ); U16( 1 ); U16( 2 );
U32( (uint)_sr ); U32( (uint)(_sr * 4) ); U16( 4 ); U16( 16 );
Str( "data" ); U32( (uint)dataSize );
for ( int i = 0; i < n; i++ )
{
ushort l = (ushort)ToS16( _bufL[i] * gain );
ushort r = (ushort)ToS16( _bufR[i] * gain );
bytes.Add( (byte)l ); bytes.Add( (byte)(l >> 8) );
bytes.Add( (byte)r ); bytes.Add( (byte)(r >> 8) );
}
return bytes.ToArray();
}
}