EngineBankSlicer.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace RedSnail.EngineBankSlicer;

/// <summary>A mono, 16-bit view of a loaded wav.</summary>
public sealed class WavData
{
	public float[] Samples;
	public int SampleRate;

	public float DurationSeconds => SampleRate > 0 ? (float)Samples.Length / SampleRate : 0.0f;
}

/// <summary>One extracted loop, named for the engine speed it was found at.</summary>
public sealed class SlicedLayer
{
	public int Rpm;
	public short[] Samples;
	public int SampleRate;

	/// <summary>Detected firing period in samples, kept for the report.</summary>
	public float Period;

	/// <summary>Whole engine cycles the loop contains — never fractional, see the slicer remarks.</summary>
	public int Cycles;
}

public sealed class SliceOptions
{
	public int Cylinders = 6;
	public bool FourStroke = true;

	/// <summary>How many loops to cut. More gives smoother crossfades and costs more voices at runtime.</summary>
	public int LayerCount = 15;

	/// <summary>Roughly how long each loop should be. Rounded to whole cycles, so it is a target, not a promise.</summary>
	public float LoopSeconds = 0.35f;

	/// <summary>Detection bounds. Anything outside is treated as a failed read rather than a real engine speed.</summary>
	public float MinRpm = 500.0f;
	public float MaxRpm = 9000.0f;

	/// <summary>
	/// Label each clip with the engine speed MEASURED from the audio, rather than interpolated between
	/// <see cref="StartRpm"/> and <see cref="EndRpm"/>.
	/// </summary>
	/// <remarks>
	/// Off by default, because measurement turned out not to be trustworthy enough to label with — and a label is
	/// the one thing that must be right, since the synthesiser derives playback pitch from it.
	///
	/// The problem is not the algorithm. Engine audio is strongly periodic at THREE different rates at once: the
	/// firing rate, the crank rate below it, and whatever the exhaust rings at, which is fixed and frequently sits
	/// inside the firing range. Correlation finds all of them and nothing in the signal says which is which.
	/// Tested against a sweep of known speed, waveform correlation locked onto the exhaust resonance and reported
	/// the same figure from idle to redline; the envelope approach that should have stripped the carrier failed
	/// too, because separating carrier from rhythm needs them not to overlap, and they do.
	///
	/// Detection is still used for LOOP LENGTH, always, and is reliable there — that only needs some true period
	/// of the signal, and the resonance period cuts just as seamless a loop as the firing period does. It is
	/// labelling that needs the one specific period, and that is the part that cannot be resolved from audio
	/// alone.
	///
	/// Turn it on if you want to try it on a particular recording; check the reported figures before trusting them.
	/// </remarks>
	public bool UseDetectedRpm = false;

	/// <summary>
	/// Engine speed at the START of the usable audio, and at the END. Normally idle and redline.
	/// </summary>
	/// <remarks>
	/// THESE SET THE LABELS. Each probe is labelled by where it sits between the two, which assumes the revs climb
	/// evenly across the recording — not strictly true, since an engine pulls hardest in the middle of its range,
	/// but predictable, and wrong by a margin the crossfade absorbs. Neighbouring layers overlap, so a clip
	/// labelled slightly off is played slightly off-pitch rather than jarringly wrong.
	///
	/// They also disambiguate octaves when <see cref="UseDetectedRpm"/> is on, where a rough figure is plenty:
	/// harmonics sit a whole multiple apart, so the prior only has to be within about 40% to pick the right one.
	///
	/// Getting these right matters more than anything else in the options. Read them off the car: idle speed and
	/// redline, or wherever the recording actually starts and stops.
	/// </remarks>
	public float StartRpm = 800.0f;
	public float EndRpm = 7000.0f;

	/// <summary>Seconds to ignore at each end — handy for trimming a key turn or a lift-off.</summary>
	public float SkipStart = 0.0f;
	public float SkipEnd = 0.0f;
}

/// <summary>
/// Cuts one recorded acceleration run — idle to redline in a single pull — into an RPM-indexed bank of loops.
/// </summary>
/// <remarks>
/// Two separate jobs, from two different sources, because they need different things:
///
/// LOOP LENGTH is measured from the audio. Each loop is cut to a whole number of signal periods and crossfaded at
/// the seam, which is what stops it clicking on every wrap. This only needs SOME true period of the waveform, and
/// correlation finds one reliably.
///
/// THE RPM LABEL comes from <see cref="SliceOptions.StartRpm"/> and <see cref="SliceOptions.EndRpm"/>, spread
/// across the recording by position. It does not come from the audio, and the reason is worth recording so nobody
/// re-attempts it: engine sound is strongly periodic at three rates at once — the firing rate, the crank rate
/// below it, and whatever the exhaust rings at, which is fixed and often lands inside the firing range. Nothing in
/// the signal says which is which. Measured against a sweep of known speed, waveform correlation reported the
/// resonance and gave near-identical figures from idle to redline; chaining probes to each other instead made the
/// first reading load-bearing and scrambled every label behind one bad probe; the envelope method that should have
/// stripped the carrier needs carrier and rhythm not to overlap, and they overlap.
///
/// The label matters more than the loop, since the synthesiser derives playback pitch from it — so it is taken
/// from the one thing that is actually known: the engine's idle and redline. That assumes revs climb evenly, which
/// is not quite true, but the error is small and neighbouring layers crossfade over it.
///
/// <see cref="SliceOptions.UseDetectedRpm"/> restores measurement for anyone who wants to try it per-recording.
/// </remarks>
public static class EngineBankSlicer
{
	/// <summary>Combustion events per crank revolution.</summary>
	public static float EventsPerRevolution(SliceOptions _Options)
	{
		return _Options.Cylinders / (_Options.FourStroke ? 2.0f : 1.0f);
	}

	public static WavData LoadWav(byte[] _Bytes)
	{
		if (_Bytes is null || _Bytes.Length < 44)
			return null;

		if (_Bytes[0] != 'R' || _Bytes[1] != 'I' || _Bytes[2] != 'F' || _Bytes[3] != 'F')
			return null;

		int channels = 1;
		int sampleRate = 44100;
		int bitsPerSample = 16;
		int dataOffset = -1;
		int dataLength = 0;

		// Walk the chunks rather than assuming a 44-byte header; plenty of wavs carry extra chunks first.
		int offset = 12;

		while (offset + 8 <= _Bytes.Length)
		{
			string id = System.Text.Encoding.ASCII.GetString(_Bytes, offset, 4);
			int size = BitConverter.ToInt32(_Bytes, offset + 4);

			if (id == "fmt ")
			{
				channels = BitConverter.ToInt16(_Bytes, offset + 10);
				sampleRate = BitConverter.ToInt32(_Bytes, offset + 12);
				bitsPerSample = BitConverter.ToInt16(_Bytes, offset + 22);
			}
			else if (id == "data")
			{
				dataOffset = offset + 8;
				dataLength = size;

				break;
			}

			offset += 8 + size + (size & 1);
		}

		if (dataOffset < 0 || bitsPerSample != 16 || channels < 1)
			return null;

		dataLength = Math.Min(dataLength, _Bytes.Length - dataOffset);

		int frames = dataLength / 2 / channels;

		if (frames <= 0)
			return null;

		float[] samples = new float[frames];

		for (int f = 0; f < frames; f++)
		{
			float sum = 0.0f;

			for (int c = 0; c < channels; c++)
				sum += BitConverter.ToInt16(_Bytes, dataOffset + ((f * channels + c) * 2));

			samples[f] = sum / channels / short.MaxValue;
		}

		return new WavData { Samples = samples, SampleRate = sampleRate };
	}

	/// <summary>
	/// Cuts the bank.
	/// </summary>
	public static List<SlicedLayer> Slice(WavData _Wav, SliceOptions _Options, out string _Report)
	{
		List<SlicedLayer> layers = new();
		System.Text.StringBuilder report = new();

		if (_Wav?.Samples is not { Length: > 0 })
		{
			_Report = "No audio loaded.";

			return layers;
		}

		int rate = _Wav.SampleRate;
		float events = EventsPerRevolution(_Options);

		if (events <= 0.0f)
		{
			_Report = "Cylinder count must be at least 1.";

			return layers;
		}

		// A low-passed copy purely for DETECTION. The firing fundamental is low and the upper harmonics are what
		// confuse autocorrelation into locking an octave high, so they are removed before measuring — but every
		// sample that gets written out comes from the untouched original.
		float[] detect = LowPass(RemoveDc(_Wav.Samples), rate, 600.0f);

		// Lag bounds straight from the RPM bounds, so detection can never report an impossible engine speed.
		int minLag = (int)(rate / (_Options.MaxRpm / 60.0f * events));
		int maxLag = (int)(rate / (_Options.MinRpm / 60.0f * events));

		minLag = Math.Max(minLag, 8);
		maxLag = Math.Min(maxLag, _Wav.Samples.Length / 4);

		if (maxLag <= minLag)
		{
			_Report = "RPM range is too narrow, or the recording is too short to measure.";

			return layers;
		}

		int window = Math.Min(maxLag * 4, _Wav.Samples.Length);

		int start = (int)(_Options.SkipStart * rate);
		int end = _Wav.Samples.Length - (int)(_Options.SkipEnd * rate);

		start = Math.Clamp(start, 0, _Wav.Samples.Length - 1);
		end = Math.Clamp(end, start + window, _Wav.Samples.Length);

		int count = Math.Max(1, _Options.LayerCount);
		int usable = end - start - window;

		if (usable <= 0)
		{
			_Report = "Nothing left to slice after the skip settings.";

			return layers;
		}

		report.AppendLine($"{_Wav.DurationSeconds:0.00}s at {rate}Hz, {events:0.#} firings/rev");

		// PASS ONE: measure every probe before deciding anything, because the correction below needs neighbours.
		int[] positions = new int[count];
		float[] firingHz = new float[count];

		for (int i = 0; i < count; i++)
		{
			// Spread the probes across the usable span. The recording's own shape decides what RPM each lands on,
			// which is why the results are rarely evenly spaced — and why they should not be forced to be.
			positions[i] = start + (count == 1 ? usable / 2 : usable * i / (count - 1));

			float period = DetectPeriod(detect, positions[i], window, minLag, maxLag);

			firingHz[i] = period > 0.0f ? rate / period : 0.0f;
		}

		if (_Options.UseDetectedRpm)
		{
			int corrected = ResolveOctaves(firingHz, _Options, events);

			report.AppendLine($"labelling from DETECTED pitch ({corrected} octave corrections) — verify these figures");
		}
		else
		{
			report.AppendLine($"labelling from the {_Options.StartRpm:0}-{_Options.EndRpm:0}rpm span; detection sets loop length only");
		}

		HashSet<int> seen = new();

		for (int i = 0; i < count; i++)
		{
			if (firingHz[i] <= 0.0f)
			{
				report.AppendLine($"  [{i}] no stable pitch found, skipped");

				continue;
			}

			// Loop length always comes from the MEASURED period — that is what makes the seam seamless, and it only
			// needs some true period of the signal, which detection supplies reliably.
			float period = rate / firingHz[i];

			// The label is a different question, and by default a different source. See UseDetectedRpm.
			float progress = count == 1 ? 0.5f : (float)i / (count - 1);

			int rpm = _Options.UseDetectedRpm
				? (int)MathF.Round(firingHz[i] * 60.0f / events)
				: (int)MathF.Round(_Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * progress);

			if (rpm < _Options.MinRpm || rpm > _Options.MaxRpm)
			{
				report.AppendLine($"  [{i}] {rpm}rpm out of range, skipped");

				continue;
			}

			// Two probes can land on the same revs when the run pauses or a gearchange flattens it. Duplicate
			// reference speeds break pair selection, which assumes strictly ascending layers.
			if (!seen.Add(rpm))
			{
				report.AppendLine($"  [{i}] {rpm}rpm duplicate, skipped");

				continue;
			}

			SlicedLayer layer = ExtractLoop(_Wav, positions[i], period, _Options.LoopSeconds);

			if (layer is null)
			{
				report.AppendLine($"  [{i}] {rpm}rpm too close to the end to loop, skipped");

				continue;
			}

			layer.Rpm = rpm;

			layers.Add(layer);

			report.AppendLine($"  {rpm,5}rpm  {layer.Cycles} cycles  {layer.Samples.Length / (float)rate:0.000}s");
		}

		layers.Sort((a, b) => a.Rpm.CompareTo(b.Rpm));

		report.AppendLine($"{layers.Count} layers");

		_Report = report.ToString();

		return layers;
	}

	/// <summary>
	/// Cuts a loop that contains a WHOLE number of firing cycles, then crossfades its seam.
	/// </summary>
	/// <remarks>
	/// A loop holding a fractional cycle restarts mid-bang, and a waveform discontinuity is a click — heard once
	/// per loop, which at a third of a second is three clicks a second and utterly damning. Snapping the length to
	/// the detected period is what makes these usable as sustained loops at all.
	///
	/// Whole cycles still leave a small mismatch, because the engine is speeding up throughout and the end of the
	/// loop is fractionally higher than its start. The crossfade takes the material just PAST the loop point and
	/// blends it over the head, so the join is a short overlap rather than a step.
	/// </remarks>
	private static SlicedLayer ExtractLoop(WavData _Wav, int _Position, float _Period, float _LoopSeconds)
	{
		int rate = _Wav.SampleRate;
		int cycles = Math.Max(2, (int)MathF.Round(_LoopSeconds * rate / _Period));
		int length = (int)MathF.Round(cycles * _Period);
		int fade = (int)MathF.Round(_Period);

		if (length <= 0 || _Position + length + fade >= _Wav.Samples.Length)
			return null;

		float[] loop = new float[length];

		Array.Copy(_Wav.Samples, _Position, loop, 0, length);

		// Blend the material just past the end over the head. After this the last sample runs into the first
		// without a step, which is what "seamless" actually means.
		for (int i = 0; i < fade && i < length; i++)
		{
			float t = (float)i / fade;

			loop[i] = loop[i] * t + _Wav.Samples[_Position + length + i] * (1.0f - t);
		}

		short[] output = new short[length];

		for (int i = 0; i < length; i++)
			output[i] = (short)(Math.Clamp(loop[i], -1.0f, 1.0f) * short.MaxValue);

		return new SlicedLayer
		{
			Samples = output,
			SampleRate = rate,
			Period = _Period,
			Cycles = cycles
		};
	}

	/// <summary>
	/// Forces the measured firing rate to rise across the recording, snapping octave errors as it goes.
	/// </summary>
	/// <remarks>
	/// THIS IS WHAT MAKES DETECTION RELIABLE, and it took measuring a known bank to find out. Pitch detection on
	/// engine audio is genuinely hard: a four-cylinder repeats every two firings per crank revolution and a V8
	/// every four, so the signal is strongly periodic at whole multiples of the firing period. Correlation-based
	/// methods lock onto those multiples about as readily as onto the truth, and no threshold separates them —
	/// tested against fifteen clips of known speed, the raw detector was exactly right below 2600rpm and exactly
	/// four times too slow above it, with nothing in the measurement itself to say which was which.
	///
	/// The recording answers it. A single pull only ever speeds UP, so a measured drop is impossible and can only
	/// be an octave error. Multiplying by the smallest whole number that restores the rise recovers the true rate.
	/// On that same bank this took the spread in firings-per-revolution from a factor of four down to 2%.
	///
	/// The catch is the first probe, which has no predecessor to be judged against: an error there shifts every
	/// later value with it. Starting the recording at a steady idle, where detection is easiest, is the defence.
	/// </remarks>
	private static int ResolveOctaves(float[] _FiringHz, SliceOptions _Options, float _EventsPerRev)
	{
		int corrected = 0;
		int count = _FiringHz.Length;

		for (int i = 0; i < count; i++)
		{
			if (_FiringHz[i] <= 0.0f)
				continue;

			// Where the prior says this probe roughly is. Straight-line, which is wrong about real engines — they
			// pull hardest in the middle — but only ever used to choose between candidates a whole multiple apart,
			// so being loose is harmless.
			float t = count == 1 ? 0.5f : (float)i / (count - 1);
			float expectedRpm = _Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * t;
			float expectedHz = MathF.Max(expectedRpm, 1.0f) / 60.0f * _EventsPerRev;

			float best = _FiringHz[i];
			float bestError = MathF.Abs(MathF.Log(best / expectedHz));

			// Compared in log space so being twice too fast and half too slow count equally — in linear terms the
			// high side would always look worse and the search would drift downward.
			foreach (float multiple in OctaveMultiples)
			{
				float candidate = _FiringHz[i] * multiple;
				float error = MathF.Abs(MathF.Log(candidate / expectedHz));

				if (error < bestError)
				{
					bestError = error;
					best = candidate;
				}
			}

			if (!best.AlmostEqual(_FiringHz[i]))
			{
				_FiringHz[i] = best;
				corrected++;
			}
		}

		return corrected;
	}

	/// <summary>
	/// Whole-number relationships a firing pattern can hide behind, and their reciprocals.
	/// </summary>
	/// <remarks>
	/// Both directions are needed. Detection can land on a subharmonic — the crank period rather than the firing
	/// period — or on an upper harmonic, and a search that could only multiply would leave the second kind wrong.
	/// </remarks>
	private static readonly float[] OctaveMultiples =
	{
		2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 8.0f,
		1.0f / 2.0f, 1.0f / 3.0f, 1.0f / 4.0f, 1.0f / 5.0f, 1.0f / 6.0f, 1.0f / 8.0f
	};

	/// <summary>
	/// Finds the firing period in samples, using the normalised square difference function.
	/// </summary>
	/// <remarks>
	/// NSDF rather than plain autocorrelation, and the FIRST strong peak rather than the tallest. Raw correlation
	/// grows with the number of terms summed and so favours long lags, which is precisely the wrong bias when the
	/// long lags are subharmonics. Normalising by the energy of both windows removes that, and taking the first
	/// peak within 10% of the best prefers the shortest period that explains the signal.
	///
	/// It is still not enough on its own — see SnapOctaves, which is what actually makes this trustworthy.
	/// </remarks>
	private static float DetectPeriod(float[] _Samples, int _Start, int _Window, int _MinLag, int _MaxLag)
	{
		if (_Start + _Window >= _Samples.Length)
			return 0.0f;

		float[] nsdf = new float[_MaxLag + 2];

		for (int lag = _MinLag; lag <= _MaxLag; lag++)
		{
			int overlap = _Window - lag;

			if (overlap <= 0)
				break;

			double correlation = 0.0;
			double energy = 0.0;

			for (int i = 0; i < overlap; i++)
			{
				float a = _Samples[_Start + i];
				float b = _Samples[_Start + i + lag];

				correlation += a * b;
				energy += a * a + b * b;
			}

			nsdf[lag] = energy > 0.000000000001 ? (float)(2.0 * correlation / energy) : 0.0f;
		}

		// Key maxima: the high point of each positive run. Ordinary local maxima are far too noisy to use.
		List<int> peaks = new();

		bool inRun = false;
		int peak = -1;

		for (int lag = _MinLag + 1; lag < _MaxLag; lag++)
		{
			if (!inRun)
			{
				if (nsdf[lag] > 0.0f && nsdf[lag] >= nsdf[lag - 1])
				{
					inRun = true;
					peak = lag;
				}

				continue;
			}

			if (nsdf[lag] > nsdf[peak])
				peak = lag;

			if (nsdf[lag] <= 0.0f)
			{
				peaks.Add(peak);
				inRun = false;
			}
		}

		if (inRun && peak > 0)
			peaks.Add(peak);

		if (peaks.Count == 0)
			return 0.0f;

		float best = 0.0f;

		foreach (int candidate in peaks)
			best = MathF.Max(best, nsdf[candidate]);

		if (best <= 0.0f)
			return 0.0f;

		foreach (int candidate in peaks)
		{
			if (nsdf[candidate] >= best * 0.9f)
				return Refine(nsdf, candidate);
		}

		return 0.0f;
	}

	/// <summary>
	/// Parabolic fit through the peak and its neighbours, for sub-sample precision.
	/// </summary>
	/// <remarks>
	/// Worth the few lines: the lag is an integer, so at high revs where the period is short, being one sample out
	/// is already a percent or two of error — tens of RPM on the label, and a permanently mistuned layer.
	/// </remarks>
	private static float Refine(float[] _Scores, int _Lag)
	{
		if (_Lag <= 0 || _Lag + 1 >= _Scores.Length)
			return _Lag;

		float previous = _Scores[_Lag - 1];
		float current = _Scores[_Lag];
		float next = _Scores[_Lag + 1];

		float denominator = previous - 2.0f * current + next;

		if (MathF.Abs(denominator) < 0.0000001f)
			return _Lag;

		float shift = 0.5f * (previous - next) / denominator;

		return _Lag + Math.Clamp(shift, -1.0f, 1.0f);
	}

	private static float[] RemoveDc(float[] _Samples)
	{
		float mean = 0.0f;

		foreach (float sample in _Samples)
			mean += sample;

		mean /= _Samples.Length;

		float[] result = new float[_Samples.Length];

		for (int i = 0; i < _Samples.Length; i++)
			result[i] = _Samples[i] - mean;

		return result;
	}

	private static float[] LowPass(float[] _Samples, int _SampleRate, float _Cutoff)
	{
		float rc = 1.0f / (MathF.Tau * _Cutoff);
		float dt = 1.0f / _SampleRate;
		float alpha = dt / (rc + dt);

		float[] result = new float[_Samples.Length];
		float value = 0.0f;

		for (int i = 0; i < _Samples.Length; i++)
		{
			value += alpha * (_Samples[i] - value);
			result[i] = value;
		}

		return result;
	}

	/// <summary>Wraps raw samples back up as a 16-bit mono PCM wav.</summary>
	public static byte[] BuildWav(short[] _Samples, int _SampleRate)
	{
		int dataLength = _Samples.Length * 2;

		using System.IO.MemoryStream stream = new();
		using System.IO.BinaryWriter writer = new(stream);

		writer.Write(System.Text.Encoding.ASCII.GetBytes("RIFF"));
		writer.Write(36 + dataLength);
		writer.Write(System.Text.Encoding.ASCII.GetBytes("WAVE"));

		writer.Write(System.Text.Encoding.ASCII.GetBytes("fmt "));
		writer.Write(16);
		writer.Write((short)1);
		writer.Write((short)1);
		writer.Write(_SampleRate);
		writer.Write(_SampleRate * 2);
		writer.Write((short)2);
		writer.Write((short)16);

		writer.Write(System.Text.Encoding.ASCII.GetBytes("data"));
		writer.Write(dataLength);

		foreach (short sample in _Samples)
			writer.Write(sample);

		writer.Flush();

		return stream.ToArray();
	}
}