Audio/MusicGen.cs

Procedural music generator for ska/reggae-rock. Given a tag seed it deterministically composes melody, bass, drums, skank, organ and horn parts, synthesizes them via simple oscillators/filters, mixes to stereo PCM and can emit WAV or interleaved samples. Supports chunked rendering for parallel synthesis.

File Access
using System;
using System.Collections.Generic;

namespace Splitclicker.Audio;

/// <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
	{
		// Output
		public int SampleRate = 32000;
		public float TargetSeconds = 80f; // bar count adapts to tempo to hit this
		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;
		public float KickVol = 1.00f;
		public float SnareVol = 0.70f;
		public float TomVol = 0.60f;
		public float HatVol = 0.22f;
		public float CrashVol = 0.35f;
		public float DrumVol = 1.00f;         // master gain over the whole kit

		// 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;

		// 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 DrumPush = 0.13f;        // push/pull timing variance magnitude (per-song bias)
		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
	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
	};

	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
	bool _organBubble;
	bool _fast;
	int _drumPush;           // per-song-constant kit timing bias in samples (− ahead / + back)

	// 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();
		var rng = new Rng( Xmur3( string.IsNullOrEmpty( tag ) ? "rotaliate" : tag.ToLowerInvariant() ) );

		_fast = rng.Chance( _c.FastChance );
		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( Scales );
		_prog = rng.Pick( Progressions );
		_rootMidi = 28 + rng.Int( 8 );                    // E1..B1 bass root
		_lead = PickInstrument( rng );
		_leadPan = (rng.Next() * 2f - 1f) * _c.PanAmount;
		_bassPat = rng.Pick( BassPatterns );
		_drumStyle = _fast ? 2 : rng.Int( 2 );            // laid-back → one-drop/steppers
		// Every song carries all melodic voices — organ bubble, skank, and horns are
		// no longer rolled on/off (a song with no organ/horns read as "missing
		// instruments"). The RNG draws are kept so songs that already had them stay
		// bit-identical downstream; only previously-bare songs gain the voice.
		rng.Chance( _c.OrganBubbleChance );               // draw kept for stream stability
		_organBubble = true;

		rng.Chance( _c.HornSectionChance );               // draw kept for stream stability
		_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) );

		float swing = _fast ? _c.FastSwing : _c.Swing;
		double secPerEighth = 60.0 / bpm / 2.0;
		int spe = (int)Math.Round( _sr * secPerEighth );

		// Drum push/pull: a per-song-constant timing bias on the whole kit (negative =
		// push ahead of the beat, positive = lay back). Constant for the song, but its
		// value varies song-to-song (own RNG stream → main composition order untouched).
		// Biased slightly toward pushing so the backbeat never feels "super late".
		var pushRng = new Rng( Xmur3( "push:" + tag ) );
		_drumPush = (int)Math.Round( (pushRng.Next() - 0.62f) * _c.DrumPush * spe );

		// Adapt bar count to the tempo so length stays ~TargetSeconds (in spec, and
		// bounds file size). Round to a multiple of 8 so it lands on a progression
		// boundary and loops cleanly.
		int barCount = Math.Max( 1, _c.Bars );
		if ( _c.TargetSeconds > 1f )
		{
			double barSec = EighthsPerBar * secPerEighth;
			barCount = Math.Clamp( (int)Math.Round( _c.TargetSeconds / barSec / 8.0 ) * 8, 16, 128 );
		}

		int total = spe * EighthsPerBar * barCount;
		_bufL = new float[total];
		_bufR = new float[total];
		var noise = new Rng( Xmur3( "drums:" + tag ) );
		var bassOrn = new Rng( Xmur3( "bass:" + tag ) );
		var hornOrn = new Rng( Xmur3( "horn:" + tag ) );

		for ( int bar = 0; bar < barCount; bar++ )
		{
			int chord = (bar / 2) % _prog.Length;
			int nextChord = ((bar / 2) + 1) % _prog.Length;
			int barStart = bar * EighthsPerBar * spe;
			bool phraseEnd = (bar % 4) == 3;              // fill the 4th bar

			RenderBassBar( barStart, spe, secPerEighth, chord, nextChord, rng, bassOrn );
			RenderRhythmBar( barStart, spe, secPerEighth, chord, swing, rng );
			RenderDrumBar( barStart, spe, chord, phraseEnd, rng, noise );

			if ( bar % 2 == 0 )
				RenderLeadPhrase( barStart, spe, secPerEighth, chord, rng );
			if ( _hasHorns )
				RenderHornStabs( barStart, spe, secPerEighth, chord, hornOrn );
		}
	}

	// 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;

		float peak = 0f;
		for ( int i = 0; i < total; i++ )
		{
			float l = (float)Math.Tanh( _bufL[i] * pre );
			float r = (float)Math.Tanh( _bufR[i] * pre );
			_bufL[i] = l; _bufR[i] = r;
			float a = Math.Max( MathF.Abs( l ), MathF.Abs( r ) );
			if ( a > peak ) peak = a;
		}
		return peak > 0.0001f ? _c.MasterPeak / peak : 1f;
	}

	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] );

	// ── Bass ──
	void RenderBassBar( int barStart, int spe, double secPerEighth, int chord, int nextChord, Rng rng, Rng bassOrn )
	{
		int root = ChordRoot( chord );
		// 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.
			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 );
				}
				continue;
			}

			EmitBass( barStart + e * spe, (int)(spe * len * 0.95f), midi, secPerEighth * len * 0.8 );
		}
	}

	void EmitBass( int at, int dur, int midi, double decaySec )
	{
		// 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.
		RenderPatch( at, dur, Midi( midi ), 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,
		} );
		RenderPatch( at, dur, Midi( midi ), 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,
		} );
	}

	// ── Skank guitar (the signature) + reggae organ bubble — offbeats, centered ──
	void RenderRhythmBar( int barStart, int spe, double secPerEighth, int chord, float swing, Rng rng )
	{
		// +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 };

		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 )
					RenderPatch( at, (int)(spe * 0.55f), Midi( ScaleMidi( gBase, d ) - 12 ), 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,
					} );
		}
	}

	// ── Lead melody (chord-tone locked → consonant) ──
	void RenderLeadPhrase( int barStart, int spe, double secPerEighth, int chord, Rng rng )
	{
		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 )];
		float amp = _c.MelodyVol;
		float drive = _c.MelodyDrive;

		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( _c.TripletChance ) )
			{
				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;
				for ( int k = 0; k < n; k++ )
				{
					int d2 = Math.Clamp( degree + (k - n / 2), _prog[chord] - 3, _prog[chord] + 10 );
					RenderLead( barStart + e * spe + k * step, (int)(step * 0.9f),
						ScaleMidi( melBase, d2 ), amp, secPerEighth * spanE / (double)n * 0.85, drive );
				}
				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 );
			}

			RenderLead( barStart + e * spe, (int)(spe * len * 0.9f), ScaleMidi( melBase, degree ),
				amp, secPerEighth * len * 0.7f, drive );
			e += len;
		}
	}

	// ── 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 )
	{
		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

		// one chord-tone voice
		void Note( int at, int dur, int k, double dec, float gain )
			=> RenderPatch( at, dur, Midi( ScaleMidi( baseMidi, degs[k] ) ), 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),
			} );

		// 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;

			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 )
	{
		switch ( _lead )
		{
			case Instrument.Trumpet:
				RenderPatch( at, dur, Midi( midi ), 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:
				RenderPatch( at, dur, Midi( midi - 12 ), 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:
				RenderPatch( at, dur, Midi( midi ), 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;
			case Instrument.Organ:
				RenderPatch( at, dur, Midi( midi ), 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;
		}
	}

	// ── 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
		public float Breath;   // 0..1 noise mix (reeds)
	}

	// 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;
		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;
			float vib = p.Vibrato > 0f ? (float)(1.0 + 0.005 * Math.Sin( i / (double)_sr * p.Vibrato * 2 * Math.PI )) : 1f;
			for ( int v = 0; v < voices; v++ )
			{
				s += Osc( p.Osc, ph[v] - Math.Floor( ph[v] ) );
				ph[v] += inc[v] * vib;
			}
			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;
			}
		}
	}

	static float Osc( int t, double p ) => t switch
	{
		0 => MathF.Sin( (float)(p * 2 * Math.PI) ),
		1 => (float)(2 * p - 1),
		2 => p < 0.5 ? 1f : -1f,
		_ => 4f * MathF.Abs( (float)p - 0.5f ) - 1f,
	};

	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 ──
	void RenderDrumBar( int barStart, int spe, int chord, bool phraseEnd, Rng rng, Rng noise )
	{
		// 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;

		// 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).
		for ( int e = 0; e < EighthsPerBar; e++ )
		{
			int at = barStart + e * spe;
			bool open = e == 7;
			RenderHat( at, open, (e % 2 == 1 ? _c.HatVol : _c.HatVol * 0.6f), noise );
			if ( !open && six > 0 && noise.Chance( busy ) )
				RenderHat( at + six, false, _c.HatVol * 0.4f, noise );
		}

		// fill the last beat of a phrase-ending bar instead of the usual hits
		if ( phraseEnd && rng.Chance( _c.FillChance ) )
		{
			RenderKickSnareGroove( barStart, spe, 0, 6, busy, noise );   // first 3 beats normal
			RenderFill( barStart + 6 * spe, spe, noise, rng );
			return;
		}
		RenderKickSnareGroove( barStart, spe, 0, EighthsPerBar, busy, noise );
	}

	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;
				default: // straight backbeat
					if ( e == 0 || e == 4 || (e == 3 && noise.Chance( _c.KickSyncChance * (0.4f + busy) )) ) 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 syncopated ghost on the "e/a" sixteenth between hits
			if ( six > 0 && e != 4 && noise.Chance( _c.GhostSnareChance * busy * 0.5f ) )
				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 };
		for ( int i = 0; i < n; i++ )
		{
			int t = at + i * step;
			if ( rng.Chance( 0.5f ) ) RenderSnare( t, noise, false );
			else RenderTom( t, toms[i], noise );
		}
		RenderCrash( at + n * step, noise ); // crash into the downbeat (may land at bar end)
	}

	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 * _drumGain;
			_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 * (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 * _drumGain;
			_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 * _drumGain;
			_bufL[start + i] += v; _bufR[start + i] += v;
		}
	}

	void RenderCrash( int start, Rng noise )
	{
		start = Math.Max( 0, start + _drumPush );
		int dur = (int)(_sr * 0.6f);
		double decay = dur * 0.45;
		float a = HpCoeff( 4000f );
		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 * _c.CrashVol * _drumGain;
			_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();
	}
}