🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=redsnail.enginebankslicer&take=20
Showing code results for query:
*
(4 total matches found)
Game
library
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();
}
}
Game
library
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();
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Engine Bank Slicer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "enginebankslicer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "redsnail" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "redsnail.enginebankslicer" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-08-22T18:12:47.9771503Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.159.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.159.0")]
Editor
library
using System;
using System.IO;
using System.Collections.Generic;
using Editor;
namespace RedSnail.EngineBankSlicer.Editor;
/// <summary>
/// Turns one recorded acceleration run into a ready-to-use bank of RPM-indexed loops.
/// </summary>
/// <remarks>
/// Point it at a single clean pull from idle to redline and it writes, for every layer it finds:
///
/// <list type="bullet">
/// <item><c><rpm>.wav</c> — the loop itself, cut to whole firing cycles and crossfaded at the seam</item>
/// <item><c><rpm>.wav.meta</c> — with <c>loop: true</c>, which is what makes s&box treat it as sustained</item>
/// <item><c><rpm>.sound</c> — a SoundEvent pointing at the compiled vsnd</item>
/// </list>
///
/// The filename is the engine speed measured from the audio, so wiring a layer is copying that number into its
/// ReferenceRpm. It also prints the prefab JSON for the whole bank, which is usually faster than filling a list
/// of fifteen entries by hand.
/// </remarks>
public static class EngineBankSlicerWindow
{
[Menu("Editor", "Vehicles/Slice Engine Bank...", "graphic_eq")]
public static void Open()
{
FileDialog dialog = new(null)
{
Title = "Choose an acceleration recording (idle to redline, one pull)",
DefaultSuffix = ".wav"
};
dialog.SetNameFilter("Audio (*.wav)");
dialog.SetFindExistingFile();
dialog.SetModeOpen();
if (!dialog.Execute())
return;
string inputPath = dialog.SelectedFile;
if (string.IsNullOrWhiteSpace(inputPath) || !File.Exists(inputPath))
return;
FileDialog output = new(null)
{
Title = "Choose the output folder (inside your project's Assets)"
};
output.SetFindDirectory();
if (!output.Execute())
return;
string outputPath = output.SelectedFile;
if (string.IsNullOrWhiteSpace(outputPath))
return;
Run(inputPath, outputPath, new SliceOptions());
}
/// <summary>
/// Does the work. Split out from the dialogs so it can be driven from code with explicit options.
/// </summary>
public static void Run(string _InputPath, string _OutputPath, SliceOptions _Options)
{
WavData wav = EngineBankSlicer.LoadWav(File.ReadAllBytes(_InputPath));
if (wav is null)
{
Log.Warning($"[EngineBankSlicer] '{_InputPath}' is not 16-bit PCM wav. Convert it and try again.");
return;
}
List<SlicedLayer> layers = EngineBankSlicer.Slice(wav, _Options, out string report);
Log.Info($"[EngineBankSlicer] {Path.GetFileName(_InputPath)}\n{report}");
if (layers.Count == 0)
{
Log.Warning("[EngineBankSlicer] Nothing usable found. Check the cylinder count first — it sets the " +
"expected firing rate, and a wrong value moves every detected RPM by the same factor.");
return;
}
Directory.CreateDirectory(_OutputPath);
// The vsnd path a SoundEvent needs is relative to Assets/ and lowercase, so recover it from wherever the
// output folder sits rather than asking for it twice.
string assetRoot = GetAssetRelativePath(_OutputPath);
if (assetRoot is null)
{
Log.Warning($"[EngineBankSlicer] '{_OutputPath}' is not inside an Assets folder, so the SoundEvents " +
$"would point nowhere. The wavs were still written; move them and regenerate.");
}
foreach (SlicedLayer layer in layers)
{
string name = layer.Rpm.ToString();
File.WriteAllBytes(Path.Combine(_OutputPath, $"{name}.wav"),
EngineBankSlicer.BuildWav(layer.Samples, layer.SampleRate));
File.WriteAllText(Path.Combine(_OutputPath, $"{name}.wav.meta"), MetaJson);
if (assetRoot is not null)
File.WriteAllText(Path.Combine(_OutputPath, $"{name}.sound"), SoundJson($"{assetRoot}/{name}.vsnd"));
}
Log.Info($"[EngineBankSlicer] Wrote {layers.Count} layers to {_OutputPath}\n\n" +
$"Paste into a VehicleNoiseSynthesizer's AccelerationLayers:\n{BuildPrefabJson(layers, assetRoot)}");
}
/// <summary>
/// Path relative to Assets/, lowercase with forward slashes — the form asset references take.
/// </summary>
private static string GetAssetRelativePath(string _FullPath)
{
string normalised = _FullPath.Replace('\\', '/');
int index = normalised.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
if (index < 0)
return null;
return normalised[(index + "/Assets/".Length)..].ToLowerInvariant().Trim('/');
}
/// <summary>The layer list, ready to paste into a prefab rather than typed in by hand fifteen times.</summary>
private static string BuildPrefabJson(List<SlicedLayer> _Layers, string _AssetRoot)
{
System.Text.StringBuilder builder = new();
builder.AppendLine("\"AccelerationLayers\": [");
for (int i = 0; i < _Layers.Count; i++)
{
SlicedLayer layer = _Layers[i];
string comma = i < _Layers.Count - 1 ? "," : "";
builder.AppendLine(" {");
builder.AppendLine($" \"Sound\": \"{_AssetRoot}/{layer.Rpm}.sound\",");
builder.AppendLine($" \"ReferenceRpm\": {layer.Rpm},");
builder.AppendLine(" \"VolumeOffset\": 0,");
builder.AppendLine(" \"PitchOffset\": 0,");
builder.AppendLine(" \"LoPitch\": 1,");
builder.AppendLine(" \"HiPitch\": 1");
builder.AppendLine($" }}{comma}");
}
builder.AppendLine("]");
return builder.ToString();
}
/// <summary>loop: true is the entire reason this file is written — without it the clips are one-shots.</summary>
private const string MetaJson =
"""
{
"loop": true,
"start": 0,
"end": 0,
"forceMono": false,
"trimSilence": false,
"normalize": false,
"gain": 0,
"rate": 44100,
"compress": false,
"bitrate": 256
}
""";
private static string SoundJson(string _VsndPath)
{
return $$"""
{
"UI": false,
"Volume": "1",
"Pitch": "1",
"Decibels": 70,
"SelectionMode": "Random",
"Sounds": [
"{{_VsndPath}}"
],
"OcclusionEnabled": true,
"Occlusion": true,
"ReverbEnabled": true,
"Reflections": true,
"AirAbsorption": true,
"Transmission": true,
"OcclusionRadius": 64,
"DistanceAttenuation": true,
"Distance": 5000,
"__references": [],
"__version": 1
}
""";
}
}
Debug: View Raw JSON Response
{
"TotalCount": 4,
"Files": [
{
"Ident": "redsnail.enginebankslicer",
"Path": "Code/EngineBankSlicer.cs",
"FileName": "EngineBankSlicer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344082,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.EngineBankSlicer;\n\n/// <summary>A mono, 16-bit view of a loaded wav.</summary>\npublic sealed class WavData\n{\n\tpublic float[] Samples;\n\tpublic int SampleRate;\n\n\tpublic float DurationSeconds => SampleRate > 0 ? (float)Samples.Length / SampleRate : 0.0f;\n}\n\n/// <summary>One extracted loop, named for the engine speed it was found at.</summary>\npublic sealed class SlicedLayer\n{\n\tpublic int Rpm;\n\tpublic short[] Samples;\n\tpublic int SampleRate;\n\n\t/// <summary>Detected firing period in samples, kept for the report.</summary>\n\tpublic float Period;\n\n\t/// <summary>Whole engine cycles the loop contains \u2014 never fractional, see the slicer remarks.</summary>\n\tpublic int Cycles;\n}\n\npublic sealed class SliceOptions\n{\n\tpublic int Cylinders = 6;\n\tpublic bool FourStroke = true;\n\n\t/// <summary>How many loops to cut. More gives smoother crossfades and costs more voices at runtime.</summary>\n\tpublic int LayerCount = 15;\n\n\t/// <summary>Roughly how long each loop should be. Rounded to whole cycles, so it is a target, not a promise.</summary>\n\tpublic float LoopSeconds = 0.35f;\n\n\t/// <summary>Detection bounds. Anything outside is treated as a failed read rather than a real engine speed.</summary>\n\tpublic float MinRpm = 500.0f;\n\tpublic float MaxRpm = 9000.0f;\n\n\t/// <summary>\n\t/// Label each clip with the engine speed MEASURED from the audio, rather than interpolated between\n\t/// <see cref=\"StartRpm\"/> and <see cref=\"EndRpm\"/>.\n\t/// </summary>\n\t/// <remarks>\n\t/// Off by default, because measurement turned out not to be trustworthy enough to label with \u2014 and a label is\n\t/// the one thing that must be right, since the synthesiser derives playback pitch from it.\n\t///\n\t/// The problem is not the algorithm. Engine audio is strongly periodic at THREE different rates at once: the\n\t/// firing rate, the crank rate below it, and whatever the exhaust rings at, which is fixed and frequently sits\n\t/// inside the firing range. Correlation finds all of them and nothing in the signal says which is which.\n\t/// Tested against a sweep of known speed, waveform correlation locked onto the exhaust resonance and reported\n\t/// the same figure from idle to redline; the envelope approach that should have stripped the carrier failed\n\t/// too, because separating carrier from rhythm needs them not to overlap, and they do.\n\t///\n\t/// Detection is still used for LOOP LENGTH, always, and is reliable there \u2014 that only needs some true period\n\t/// of the signal, and the resonance period cuts just as seamless a loop as the firing period does. It is\n\t/// labelling that needs the one specific period, and that is the part that cannot be resolved from audio\n\t/// alone.\n\t///\n\t/// Turn it on if you want to try it on a particular recording; check the reported figures before trusting them.\n\t/// </remarks>\n\tpublic bool UseDetectedRpm = false;\n\n\t/// <summary>\n\t/// Engine speed at the START of the usable audio, and at the END. Normally idle and redline.\n\t/// </summary>\n\t/// <remarks>\n\t/// THESE SET THE LABELS. Each probe is labelled by where it sits between the two, which assumes the revs climb\n\t/// evenly across the recording \u2014 not strictly true, since an engine pulls hardest in the middle of its range,\n\t/// but predictable, and wrong by a margin the crossfade absorbs. Neighbouring layers overlap, so a clip\n\t/// labelled slightly off is played slightly off-pitch rather than jarringly wrong.\n\t///\n\t/// They also disambiguate octaves when <see cref=\"UseDetectedRpm\"/> is on, where a rough figure is plenty:\n\t/// harmonics sit a whole multiple apart, so the prior only has to be within about 40% to pick the right one.\n\t///\n\t/// Getting these right matters more than anything else in the options. Read them off the car: idle speed and\n\t/// redline, or wherever the recording actually starts and stops.\n\t/// </remarks>\n\tpublic float StartRpm = 800.0f;\n\tpublic float EndRpm = 7000.0f;\n\n\t/// <summary>Seconds to ignore at each end \u2014 handy for trimming a key turn or a lift-off.</summary>\n\tpublic float SkipStart = 0.0f;\n\tpublic float SkipEnd = 0.0f;\n}\n\n/// <summary>\n/// Cuts one recorded acceleration run \u2014 idle to redline in a single pull \u2014 into an RPM-indexed bank of loops.\n/// </summary>\n/// <remarks>\n/// Two separate jobs, from two different sources, because they need different things:\n///\n/// LOOP LENGTH is measured from the audio. Each loop is cut to a whole number of signal periods and crossfaded at\n/// the seam, which is what stops it clicking on every wrap. This only needs SOME true period of the waveform, and\n/// correlation finds one reliably.\n///\n/// THE RPM LABEL comes from <see cref=\"SliceOptions.StartRpm\"/> and <see cref=\"SliceOptions.EndRpm\"/>, spread\n/// across the recording by position. It does not come from the audio, and the reason is worth recording so nobody\n/// re-attempts it: engine sound is strongly periodic at three rates at once \u2014 the firing rate, the crank rate\n/// below it, and whatever the exhaust rings at, which is fixed and often lands inside the firing range. Nothing in\n/// the signal says which is which. Measured against a sweep of known speed, waveform correlation reported the\n/// resonance and gave near-identical figures from idle to redline; chaining probes to each other instead made the\n/// first reading load-bearing and scrambled every label behind one bad probe; the envelope method that should have\n/// stripped the carrier needs carrier and rhythm not to overlap, and they overlap.\n///\n/// The label matters more than the loop, since the synthesiser derives playback pitch from it \u2014 so it is taken\n/// from the one thing that is actually known: the engine's idle and redline. That assumes revs climb evenly, which\n/// is not quite true, but the error is small and neighbouring layers crossfade over it.\n///\n/// <see cref=\"SliceOptions.UseDetectedRpm\"/> restores measurement for anyone who wants to try it per-recording.\n/// </remarks>\npublic static class EngineBankSlicer\n{\n\t/// <summary>Combustion events per crank revolution.</summary>\n\tpublic static float EventsPerRevolution(SliceOptions _Options)\n\t{\n\t\treturn _Options.Cylinders / (_Options.FourStroke ? 2.0f : 1.0f);\n\t}\n\n\tpublic static WavData LoadWav(byte[] _Bytes)\n\t{\n\t\tif (_Bytes is null || _Bytes.Length < 44)\n\t\t\treturn null;\n\n\t\tif (_Bytes[0] != 'R' || _Bytes[1] != 'I' || _Bytes[2] != 'F' || _Bytes[3] != 'F')\n\t\t\treturn null;\n\n\t\tint channels = 1;\n\t\tint sampleRate = 44100;\n\t\tint bitsPerSample = 16;\n\t\tint dataOffset = -1;\n\t\tint dataLength = 0;\n\n\t\t// Walk the chunks rather than assuming a 44-byte header; plenty of wavs carry extra chunks first.\n\t\tint offset = 12;\n\n\t\twhile (offset + 8 <= _Bytes.Length)\n\t\t{\n\t\t\tstring id = System.Text.Encoding.ASCII.GetString(_Bytes, offset, 4);\n\t\t\tint size = BitConverter.ToInt32(_Bytes, offset + 4);\n\n\t\t\tif (id == \"fmt \")\n\t\t\t{\n\t\t\t\tchannels = BitConverter.ToInt16(_Bytes, offset + 10);\n\t\t\t\tsampleRate = BitConverter.ToInt32(_Bytes, offset + 12);\n\t\t\t\tbitsPerSample = BitConverter.ToInt16(_Bytes, offset + 22);\n\t\t\t}\n\t\t\telse if (id == \"data\")\n\t\t\t{\n\t\t\t\tdataOffset = offset + 8;\n\t\t\t\tdataLength = size;\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\toffset += 8 + size + (size & 1);\n\t\t}\n\n\t\tif (dataOffset < 0 || bitsPerSample != 16 || channels < 1)\n\t\t\treturn null;\n\n\t\tdataLength = Math.Min(dataLength, _Bytes.Length - dataOffset);\n\n\t\tint frames = dataLength / 2 / channels;\n\n\t\tif (frames <= 0)\n\t\t\treturn null;\n\n\t\tfloat[] samples = new float[frames];\n\n\t\tfor (int f = 0; f < frames; f++)\n\t\t{\n\t\t\tfloat sum = 0.0f;\n\n\t\t\tfor (int c = 0; c < channels; c++)\n\t\t\t\tsum += BitConverter.ToInt16(_Bytes, dataOffset + ((f * channels + c) * 2));\n\n\t\t\tsamples[f] = sum / channels / short.MaxValue;\n\t\t}\n\n\t\treturn new WavData { Samples = samples, SampleRate = sampleRate };\n\t}\n\n\t/// <summary>\n\t/// Cuts the bank.\n\t/// </summary>\n\tpublic static List<SlicedLayer> Slice(WavData _Wav, SliceOptions _Options, out string _Report)\n\t{\n\t\tList<SlicedLayer> layers = new();\n\t\tSystem.Text.StringBuilder report = new();\n\n\t\tif (_Wav?.Samples is not { Length: > 0 })\n\t\t{\n\t\t\t_Report = \"No audio loaded.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\tint rate = _Wav.SampleRate;\n\t\tfloat events = EventsPerRevolution(_Options);\n\n\t\tif (events <= 0.0f)\n\t\t{\n\t\t\t_Report = \"Cylinder count must be at least 1.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\t// A low-passed copy purely for DETECTION. The firing fundamental is low and the upper harmonics are what\n\t\t// confuse autocorrelation into locking an octave high, so they are removed before measuring \u2014 but every\n\t\t// sample that gets written out comes from the untouched original.\n\t\tfloat[] detect = LowPass(RemoveDc(_Wav.Samples), rate, 600.0f);\n\n\t\t// Lag bounds straight from the RPM bounds, so detection can never report an impossible engine speed.\n\t\tint minLag = (int)(rate / (_Options.MaxRpm / 60.0f * events));\n\t\tint maxLag = (int)(rate / (_Options.MinRpm / 60.0f * events));\n\n\t\tminLag = Math.Max(minLag, 8);\n\t\tmaxLag = Math.Min(maxLag, _Wav.Samples.Length / 4);\n\n\t\tif (maxLag <= minLag)\n\t\t{\n\t\t\t_Report = \"RPM range is too narrow, or the recording is too short to measure.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\tint window = Math.Min(maxLag * 4, _Wav.Samples.Length);\n\n\t\tint start = (int)(_Options.SkipStart * rate);\n\t\tint end = _Wav.Samples.Length - (int)(_Options.SkipEnd * rate);\n\n\t\tstart = Math.Clamp(start, 0, _Wav.Samples.Length - 1);\n\t\tend = Math.Clamp(end, start + window, _Wav.Samples.Length);\n\n\t\tint count = Math.Max(1, _Options.LayerCount);\n\t\tint usable = end - start - window;\n\n\t\tif (usable <= 0)\n\t\t{\n\t\t\t_Report = \"Nothing left to slice after the skip settings.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\treport.AppendLine($\"{_Wav.DurationSeconds:0.00}s at {rate}Hz, {events:0.#} firings/rev\");\n\n\t\t// PASS ONE: measure every probe before deciding anything, because the correction below needs neighbours.\n\t\tint[] positions = new int[count];\n\t\tfloat[] firingHz = new float[count];\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\t// Spread the probes across the usable span. The recording's own shape decides what RPM each lands on,\n\t\t\t// which is why the results are rarely evenly spaced \u2014 and why they should not be forced to be.\n\t\t\tpositions[i] = start + (count == 1 ? usable / 2 : usable * i / (count - 1));\n\n\t\t\tfloat period = DetectPeriod(detect, positions[i], window, minLag, maxLag);\n\n\t\t\tfiringHz[i] = period > 0.0f ? rate / period : 0.0f;\n\t\t}\n\n\t\tif (_Options.UseDetectedRpm)\n\t\t{\n\t\t\tint corrected = ResolveOctaves(firingHz, _Options, events);\n\n\t\t\treport.AppendLine($\"labelling from DETECTED pitch ({corrected} octave corrections) \u2014 verify these figures\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.AppendLine($\"labelling from the {_Options.StartRpm:0}-{_Options.EndRpm:0}rpm span; detection sets loop length only\");\n\t\t}\n\n\t\tHashSet<int> seen = new();\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\tif (firingHz[i] <= 0.0f)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] no stable pitch found, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Loop length always comes from the MEASURED period \u2014 that is what makes the seam seamless, and it only\n\t\t\t// needs some true period of the signal, which detection supplies reliably.\n\t\t\tfloat period = rate / firingHz[i];\n\n\t\t\t// The label is a different question, and by default a different source. See UseDetectedRpm.\n\t\t\tfloat progress = count == 1 ? 0.5f : (float)i / (count - 1);\n\n\t\t\tint rpm = _Options.UseDetectedRpm\n\t\t\t\t? (int)MathF.Round(firingHz[i] * 60.0f / events)\n\t\t\t\t: (int)MathF.Round(_Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * progress);\n\n\t\t\tif (rpm < _Options.MinRpm || rpm > _Options.MaxRpm)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm out of range, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Two probes can land on the same revs when the run pauses or a gearchange flattens it. Duplicate\n\t\t\t// reference speeds break pair selection, which assumes strictly ascending layers.\n\t\t\tif (!seen.Add(rpm))\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm duplicate, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tSlicedLayer layer = ExtractLoop(_Wav, positions[i], period, _Options.LoopSeconds);\n\n\t\t\tif (layer is null)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm too close to the end to loop, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlayer.Rpm = rpm;\n\n\t\t\tlayers.Add(layer);\n\n\t\t\treport.AppendLine($\" {rpm,5}rpm {layer.Cycles} cycles {layer.Samples.Length / (float)rate:0.000}s\");\n\t\t}\n\n\t\tlayers.Sort((a, b) => a.Rpm.CompareTo(b.Rpm));\n\n\t\treport.AppendLine($\"{layers.Count} layers\");\n\n\t\t_Report = report.ToString();\n\n\t\treturn layers;\n\t}\n\n\t/// <summary>\n\t/// Cuts a loop that contains a WHOLE number of firing cycles, then crossfades its seam.\n\t/// </summary>\n\t/// <remarks>\n\t/// A loop holding a fractional cycle restarts mid-bang, and a waveform discontinuity is a click \u2014 heard once\n\t/// per loop, which at a third of a second is three clicks a second and utterly damning. Snapping the length to\n\t/// the detected period is what makes these usable as sustained loops at all.\n\t///\n\t/// Whole cycles still leave a small mismatch, because the engine is speeding up throughout and the end of the\n\t/// loop is fractionally higher than its start. The crossfade takes the material just PAST the loop point and\n\t/// blends it over the head, so the join is a short overlap rather than a step.\n\t/// </remarks>\n\tprivate static SlicedLayer ExtractLoop(WavData _Wav, int _Position, float _Period, float _LoopSeconds)\n\t{\n\t\tint rate = _Wav.SampleRate;\n\t\tint cycles = Math.Max(2, (int)MathF.Round(_LoopSeconds * rate / _Period));\n\t\tint length = (int)MathF.Round(cycles * _Period);\n\t\tint fade = (int)MathF.Round(_Period);\n\n\t\tif (length <= 0 || _Position + length + fade >= _Wav.Samples.Length)\n\t\t\treturn null;\n\n\t\tfloat[] loop = new float[length];\n\n\t\tArray.Copy(_Wav.Samples, _Position, loop, 0, length);\n\n\t\t// Blend the material just past the end over the head. After this the last sample runs into the first\n\t\t// without a step, which is what \"seamless\" actually means.\n\t\tfor (int i = 0; i < fade && i < length; i++)\n\t\t{\n\t\t\tfloat t = (float)i / fade;\n\n\t\t\tloop[i] = loop[i] * t + _Wav.Samples[_Position + length + i] * (1.0f - t);\n\t\t}\n\n\t\tshort[] output = new short[length];\n\n\t\tfor (int i = 0; i < length; i++)\n\t\t\toutput[i] = (short)(Math.Clamp(loop[i], -1.0f, 1.0f) * short.MaxValue);\n\n\t\treturn new SlicedLayer\n\t\t{\n\t\t\tSamples = output,\n\t\t\tSampleRate = rate,\n\t\t\tPeriod = _Period,\n\t\t\tCycles = cycles\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Forces the measured firing rate to rise across the recording, snapping octave errors as it goes.\n\t/// </summary>\n\t/// <remarks>\n\t/// THIS IS WHAT MAKES DETECTION RELIABLE, and it took measuring a known bank to find out. Pitch detection on\n\t/// engine audio is genuinely hard: a four-cylinder repeats every two firings per crank revolution and a V8\n\t/// every four, so the signal is strongly periodic at whole multiples of the firing period. Correlation-based\n\t/// methods lock onto those multiples about as readily as onto the truth, and no threshold separates them \u2014\n\t/// tested against fifteen clips of known speed, the raw detector was exactly right below 2600rpm and exactly\n\t/// four times too slow above it, with nothing in the measurement itself to say which was which.\n\t///\n\t/// The recording answers it. A single pull only ever speeds UP, so a measured drop is impossible and can only\n\t/// be an octave error. Multiplying by the smallest whole number that restores the rise recovers the true rate.\n\t/// On that same bank this took the spread in firings-per-revolution from a factor of four down to 2%.\n\t///\n\t/// The catch is the first probe, which has no predecessor to be judged against: an error there shifts every\n\t/// later value with it. Starting the recording at a steady idle, where detection is easiest, is the defence.\n\t/// </remarks>\n\tprivate static int ResolveOctaves(float[] _FiringHz, SliceOptions _Options, float _EventsPerRev)\n\t{\n\t\tint corrected = 0;\n\t\tint count = _FiringHz.Length;\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\tif (_FiringHz[i] <= 0.0f)\n\t\t\t\tcontinue;\n\n\t\t\t// Where the prior says this probe roughly is. Straight-line, which is wrong about real engines \u2014 they\n\t\t\t// pull hardest in the middle \u2014 but only ever used to choose between candidates a whole multiple apart,\n\t\t\t// so being loose is harmless.\n\t\t\tfloat t = count == 1 ? 0.5f : (float)i / (count - 1);\n\t\t\tfloat expectedRpm = _Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * t;\n\t\t\tfloat expectedHz = MathF.Max(expectedRpm, 1.0f) / 60.0f * _EventsPerRev;\n\n\t\t\tfloat best = _FiringHz[i];\n\t\t\tfloat bestError = MathF.Abs(MathF.Log(best / expectedHz));\n\n\t\t\t// Compared in log space so being twice too fast and half too slow count equally \u2014 in linear terms the\n\t\t\t// high side would always look worse and the search would drift downward.\n\t\t\tforeach (float multiple in OctaveMultiples)\n\t\t\t{\n\t\t\t\tfloat candidate = _FiringHz[i] * multiple;\n\t\t\t\tfloat error = MathF.Abs(MathF.Log(candidate / expectedHz));\n\n\t\t\t\tif (error < bestError)\n\t\t\t\t{\n\t\t\t\t\tbestError = error;\n\t\t\t\t\tbest = candidate;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!best.AlmostEqual(_FiringHz[i]))\n\t\t\t{\n\t\t\t\t_FiringHz[i] = best;\n\t\t\t\tcorrected++;\n\t\t\t}\n\t\t}\n\n\t\treturn corrected;\n\t}\n\n\t/// <summary>\n\t/// Whole-number relationships a firing pattern can hide behind, and their reciprocals.\n\t/// </summary>\n\t/// <remarks>\n\t/// Both directions are needed. Detection can land on a subharmonic \u2014 the crank period rather than the firing\n\t/// period \u2014 or on an upper harmonic, and a search that could only multiply would leave the second kind wrong.\n\t/// </remarks>\n\tprivate static readonly float[] OctaveMultiples =\n\t{\n\t\t2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 8.0f,\n\t\t1.0f / 2.0f, 1.0f / 3.0f, 1.0f / 4.0f, 1.0f / 5.0f, 1.0f / 6.0f, 1.0f / 8.0f\n\t};\n\n\t/// <summary>\n\t/// Finds the firing period in samples, using the normalised square difference function.\n\t/// </summary>\n\t/// <remarks>\n\t/// NSDF rather than plain autocorrelation, and the FIRST strong peak rather than the tallest. Raw correlation\n\t/// grows with the number of terms summed and so favours long lags, which is precisely the wrong bias when the\n\t/// long lags are subharmonics. Normalising by the energy of both windows removes that, and taking the first\n\t/// peak within 10% of the best prefers the shortest period that explains the signal.\n\t///\n\t/// It is still not enough on its own \u2014 see SnapOctaves, which is what actually makes this trustworthy.\n\t/// </remarks>\n\tprivate static float DetectPeriod(float[] _Samples, int _Start, int _Window, int _MinLag, int _MaxLag)\n\t{\n\t\tif (_Start + _Window >= _Samples.Length)\n\t\t\treturn 0.0f;\n\n\t\tfloat[] nsdf = new float[_MaxLag + 2];\n\n\t\tfor (int lag = _MinLag; lag <= _MaxLag; lag++)\n\t\t{\n\t\t\tint overlap = _Window - lag;\n\n\t\t\tif (overlap <= 0)\n\t\t\t\tbreak;\n\n\t\t\tdouble correlation = 0.0;\n\t\t\tdouble energy = 0.0;\n\n\t\t\tfor (int i = 0; i < overlap; i++)\n\t\t\t{\n\t\t\t\tfloat a = _Samples[_Start + i];\n\t\t\t\tfloat b = _Samples[_Start + i + lag];\n\n\t\t\t\tcorrelation += a * b;\n\t\t\t\tenergy += a * a + b * b;\n\t\t\t}\n\n\t\t\tnsdf[lag] = energy > 0.000000000001 ? (float)(2.0 * correlation / energy) : 0.0f;\n\t\t}\n\n\t\t// Key maxima: the high point of each positive run. Ordinary local maxima are far too noisy to use.\n\t\tList<int> peaks = new();\n\n\t\tbool inRun = false;\n\t\tint peak = -1;\n\n\t\tfor (int lag = _MinLag + 1; lag < _MaxLag; lag++)\n\t\t{\n\t\t\tif (!inRun)\n\t\t\t{\n\t\t\t\tif (nsdf[lag] > 0.0f && nsdf[lag] >= nsdf[lag - 1])\n\t\t\t\t{\n\t\t\t\t\tinRun = true;\n\t\t\t\t\tpeak = lag;\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (nsdf[lag] > nsdf[peak])\n\t\t\t\tpeak = lag;\n\n\t\t\tif (nsdf[lag] <= 0.0f)\n\t\t\t{\n\t\t\t\tpeaks.Add(peak);\n\t\t\t\tinRun = false;\n\t\t\t}\n\t\t}\n\n\t\tif (inRun && peak > 0)\n\t\t\tpeaks.Add(peak);\n\n\t\tif (peaks.Count == 0)\n\t\t\treturn 0.0f;\n\n\t\tfloat best = 0.0f;\n\n\t\tforeach (int candidate in peaks)\n\t\t\tbest = MathF.Max(best, nsdf[candidate]);\n\n\t\tif (best <= 0.0f)\n\t\t\treturn 0.0f;\n\n\t\tforeach (int candidate in peaks)\n\t\t{\n\t\t\tif (nsdf[candidate] >= best * 0.9f)\n\t\t\t\treturn Refine(nsdf, candidate);\n\t\t}\n\n\t\treturn 0.0f;\n\t}\n\n\t/// <summary>\n\t/// Parabolic fit through the peak and its neighbours, for sub-sample precision.\n\t/// </summary>\n\t/// <remarks>\n\t/// Worth the few lines: the lag is an integer, so at high revs where the period is short, being one sample out\n\t/// is already a percent or two of error \u2014 tens of RPM on the label, and a permanently mistuned layer.\n\t/// </remarks>\n\tprivate static float Refine(float[] _Scores, int _Lag)\n\t{\n\t\tif (_Lag <= 0 || _Lag + 1 >= _Scores.Length)\n\t\t\treturn _Lag;\n\n\t\tfloat previous = _Scores[_Lag - 1];\n\t\tfloat current = _Scores[_Lag];\n\t\tfloat next = _Scores[_Lag + 1];\n\n\t\tfloat denominator = previous - 2.0f * current + next;\n\n\t\tif (MathF.Abs(denominator) < 0.0000001f)\n\t\t\treturn _Lag;\n\n\t\tfloat shift = 0.5f * (previous - next) / denominator;\n\n\t\treturn _Lag + Math.Clamp(shift, -1.0f, 1.0f);\n\t}\n\n\tprivate static float[] RemoveDc(float[] _Samples)\n\t{\n\t\tfloat mean = 0.0f;\n\n\t\tforeach (float sample in _Samples)\n\t\t\tmean += sample;\n\n\t\tmean /= _Samples.Length;\n\n\t\tfloat[] result = new float[_Samples.Length];\n\n\t\tfor (int i = 0; i < _Samples.Length; i++)\n\t\t\tresult[i] = _Samples[i] - mean;\n\n\t\treturn result;\n\t}\n\n\tprivate static float[] LowPass(float[] _Samples, int _SampleRate, float _Cutoff)\n\t{\n\t\tfloat rc = 1.0f / (MathF.Tau * _Cutoff);\n\t\tfloat dt = 1.0f / _SampleRate;\n\t\tfloat alpha = dt / (rc + dt);\n\n\t\tfloat[] result = new float[_Samples.Length];\n\t\tfloat value = 0.0f;\n\n\t\tfor (int i = 0; i < _Samples.Length; i++)\n\t\t{\n\t\t\tvalue += alpha * (_Samples[i] - value);\n\t\t\tresult[i] = value;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// <summary>Wraps raw samples back up as a 16-bit mono PCM wav.</summary>\n\tpublic static byte[] BuildWav(short[] _Samples, int _SampleRate)\n\t{\n\t\tint dataLength = _Samples.Length * 2;\n\n\t\tusing System.IO.MemoryStream stream = new();\n\t\tusing System.IO.BinaryWriter writer = new(stream);\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"RIFF\"));\n\t\twriter.Write(36 + dataLength);\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"WAVE\"));\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"fmt \"));\n\t\twriter.Write(16);\n\t\twriter.Write((short)1);\n\t\twriter.Write((short)1);\n\t\twriter.Write(_SampleRate);\n\t\twriter.Write(_SampleRate * 2);\n\t\twriter.Write((short)2);\n\t\twriter.Write((short)16);\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"data\"));\n\t\twriter.Write(dataLength);\n\n\t\tforeach (short sample in _Samples)\n\t\t\twriter.Write(sample);\n\n\t\twriter.Flush();\n\n\t\treturn stream.ToArray();\n\t}\n}\n"
},
{
"Ident": "redsnail.enginebankslicer",
"Path": "EngineBankSlicer.cs",
"FileName": "EngineBankSlicer.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344082,
"Code": "using System;\nusing System.Collections.Generic;\nusing Sandbox;\n\nnamespace RedSnail.EngineBankSlicer;\n\n/// <summary>A mono, 16-bit view of a loaded wav.</summary>\npublic sealed class WavData\n{\n\tpublic float[] Samples;\n\tpublic int SampleRate;\n\n\tpublic float DurationSeconds => SampleRate > 0 ? (float)Samples.Length / SampleRate : 0.0f;\n}\n\n/// <summary>One extracted loop, named for the engine speed it was found at.</summary>\npublic sealed class SlicedLayer\n{\n\tpublic int Rpm;\n\tpublic short[] Samples;\n\tpublic int SampleRate;\n\n\t/// <summary>Detected firing period in samples, kept for the report.</summary>\n\tpublic float Period;\n\n\t/// <summary>Whole engine cycles the loop contains \u2014 never fractional, see the slicer remarks.</summary>\n\tpublic int Cycles;\n}\n\npublic sealed class SliceOptions\n{\n\tpublic int Cylinders = 6;\n\tpublic bool FourStroke = true;\n\n\t/// <summary>How many loops to cut. More gives smoother crossfades and costs more voices at runtime.</summary>\n\tpublic int LayerCount = 15;\n\n\t/// <summary>Roughly how long each loop should be. Rounded to whole cycles, so it is a target, not a promise.</summary>\n\tpublic float LoopSeconds = 0.35f;\n\n\t/// <summary>Detection bounds. Anything outside is treated as a failed read rather than a real engine speed.</summary>\n\tpublic float MinRpm = 500.0f;\n\tpublic float MaxRpm = 9000.0f;\n\n\t/// <summary>\n\t/// Label each clip with the engine speed MEASURED from the audio, rather than interpolated between\n\t/// <see cref=\"StartRpm\"/> and <see cref=\"EndRpm\"/>.\n\t/// </summary>\n\t/// <remarks>\n\t/// Off by default, because measurement turned out not to be trustworthy enough to label with \u2014 and a label is\n\t/// the one thing that must be right, since the synthesiser derives playback pitch from it.\n\t///\n\t/// The problem is not the algorithm. Engine audio is strongly periodic at THREE different rates at once: the\n\t/// firing rate, the crank rate below it, and whatever the exhaust rings at, which is fixed and frequently sits\n\t/// inside the firing range. Correlation finds all of them and nothing in the signal says which is which.\n\t/// Tested against a sweep of known speed, waveform correlation locked onto the exhaust resonance and reported\n\t/// the same figure from idle to redline; the envelope approach that should have stripped the carrier failed\n\t/// too, because separating carrier from rhythm needs them not to overlap, and they do.\n\t///\n\t/// Detection is still used for LOOP LENGTH, always, and is reliable there \u2014 that only needs some true period\n\t/// of the signal, and the resonance period cuts just as seamless a loop as the firing period does. It is\n\t/// labelling that needs the one specific period, and that is the part that cannot be resolved from audio\n\t/// alone.\n\t///\n\t/// Turn it on if you want to try it on a particular recording; check the reported figures before trusting them.\n\t/// </remarks>\n\tpublic bool UseDetectedRpm = false;\n\n\t/// <summary>\n\t/// Engine speed at the START of the usable audio, and at the END. Normally idle and redline.\n\t/// </summary>\n\t/// <remarks>\n\t/// THESE SET THE LABELS. Each probe is labelled by where it sits between the two, which assumes the revs climb\n\t/// evenly across the recording \u2014 not strictly true, since an engine pulls hardest in the middle of its range,\n\t/// but predictable, and wrong by a margin the crossfade absorbs. Neighbouring layers overlap, so a clip\n\t/// labelled slightly off is played slightly off-pitch rather than jarringly wrong.\n\t///\n\t/// They also disambiguate octaves when <see cref=\"UseDetectedRpm\"/> is on, where a rough figure is plenty:\n\t/// harmonics sit a whole multiple apart, so the prior only has to be within about 40% to pick the right one.\n\t///\n\t/// Getting these right matters more than anything else in the options. Read them off the car: idle speed and\n\t/// redline, or wherever the recording actually starts and stops.\n\t/// </remarks>\n\tpublic float StartRpm = 800.0f;\n\tpublic float EndRpm = 7000.0f;\n\n\t/// <summary>Seconds to ignore at each end \u2014 handy for trimming a key turn or a lift-off.</summary>\n\tpublic float SkipStart = 0.0f;\n\tpublic float SkipEnd = 0.0f;\n}\n\n/// <summary>\n/// Cuts one recorded acceleration run \u2014 idle to redline in a single pull \u2014 into an RPM-indexed bank of loops.\n/// </summary>\n/// <remarks>\n/// Two separate jobs, from two different sources, because they need different things:\n///\n/// LOOP LENGTH is measured from the audio. Each loop is cut to a whole number of signal periods and crossfaded at\n/// the seam, which is what stops it clicking on every wrap. This only needs SOME true period of the waveform, and\n/// correlation finds one reliably.\n///\n/// THE RPM LABEL comes from <see cref=\"SliceOptions.StartRpm\"/> and <see cref=\"SliceOptions.EndRpm\"/>, spread\n/// across the recording by position. It does not come from the audio, and the reason is worth recording so nobody\n/// re-attempts it: engine sound is strongly periodic at three rates at once \u2014 the firing rate, the crank rate\n/// below it, and whatever the exhaust rings at, which is fixed and often lands inside the firing range. Nothing in\n/// the signal says which is which. Measured against a sweep of known speed, waveform correlation reported the\n/// resonance and gave near-identical figures from idle to redline; chaining probes to each other instead made the\n/// first reading load-bearing and scrambled every label behind one bad probe; the envelope method that should have\n/// stripped the carrier needs carrier and rhythm not to overlap, and they overlap.\n///\n/// The label matters more than the loop, since the synthesiser derives playback pitch from it \u2014 so it is taken\n/// from the one thing that is actually known: the engine's idle and redline. That assumes revs climb evenly, which\n/// is not quite true, but the error is small and neighbouring layers crossfade over it.\n///\n/// <see cref=\"SliceOptions.UseDetectedRpm\"/> restores measurement for anyone who wants to try it per-recording.\n/// </remarks>\npublic static class EngineBankSlicer\n{\n\t/// <summary>Combustion events per crank revolution.</summary>\n\tpublic static float EventsPerRevolution(SliceOptions _Options)\n\t{\n\t\treturn _Options.Cylinders / (_Options.FourStroke ? 2.0f : 1.0f);\n\t}\n\n\tpublic static WavData LoadWav(byte[] _Bytes)\n\t{\n\t\tif (_Bytes is null || _Bytes.Length < 44)\n\t\t\treturn null;\n\n\t\tif (_Bytes[0] != 'R' || _Bytes[1] != 'I' || _Bytes[2] != 'F' || _Bytes[3] != 'F')\n\t\t\treturn null;\n\n\t\tint channels = 1;\n\t\tint sampleRate = 44100;\n\t\tint bitsPerSample = 16;\n\t\tint dataOffset = -1;\n\t\tint dataLength = 0;\n\n\t\t// Walk the chunks rather than assuming a 44-byte header; plenty of wavs carry extra chunks first.\n\t\tint offset = 12;\n\n\t\twhile (offset + 8 <= _Bytes.Length)\n\t\t{\n\t\t\tstring id = System.Text.Encoding.ASCII.GetString(_Bytes, offset, 4);\n\t\t\tint size = BitConverter.ToInt32(_Bytes, offset + 4);\n\n\t\t\tif (id == \"fmt \")\n\t\t\t{\n\t\t\t\tchannels = BitConverter.ToInt16(_Bytes, offset + 10);\n\t\t\t\tsampleRate = BitConverter.ToInt32(_Bytes, offset + 12);\n\t\t\t\tbitsPerSample = BitConverter.ToInt16(_Bytes, offset + 22);\n\t\t\t}\n\t\t\telse if (id == \"data\")\n\t\t\t{\n\t\t\t\tdataOffset = offset + 8;\n\t\t\t\tdataLength = size;\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\toffset += 8 + size + (size & 1);\n\t\t}\n\n\t\tif (dataOffset < 0 || bitsPerSample != 16 || channels < 1)\n\t\t\treturn null;\n\n\t\tdataLength = Math.Min(dataLength, _Bytes.Length - dataOffset);\n\n\t\tint frames = dataLength / 2 / channels;\n\n\t\tif (frames <= 0)\n\t\t\treturn null;\n\n\t\tfloat[] samples = new float[frames];\n\n\t\tfor (int f = 0; f < frames; f++)\n\t\t{\n\t\t\tfloat sum = 0.0f;\n\n\t\t\tfor (int c = 0; c < channels; c++)\n\t\t\t\tsum += BitConverter.ToInt16(_Bytes, dataOffset + ((f * channels + c) * 2));\n\n\t\t\tsamples[f] = sum / channels / short.MaxValue;\n\t\t}\n\n\t\treturn new WavData { Samples = samples, SampleRate = sampleRate };\n\t}\n\n\t/// <summary>\n\t/// Cuts the bank.\n\t/// </summary>\n\tpublic static List<SlicedLayer> Slice(WavData _Wav, SliceOptions _Options, out string _Report)\n\t{\n\t\tList<SlicedLayer> layers = new();\n\t\tSystem.Text.StringBuilder report = new();\n\n\t\tif (_Wav?.Samples is not { Length: > 0 })\n\t\t{\n\t\t\t_Report = \"No audio loaded.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\tint rate = _Wav.SampleRate;\n\t\tfloat events = EventsPerRevolution(_Options);\n\n\t\tif (events <= 0.0f)\n\t\t{\n\t\t\t_Report = \"Cylinder count must be at least 1.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\t// A low-passed copy purely for DETECTION. The firing fundamental is low and the upper harmonics are what\n\t\t// confuse autocorrelation into locking an octave high, so they are removed before measuring \u2014 but every\n\t\t// sample that gets written out comes from the untouched original.\n\t\tfloat[] detect = LowPass(RemoveDc(_Wav.Samples), rate, 600.0f);\n\n\t\t// Lag bounds straight from the RPM bounds, so detection can never report an impossible engine speed.\n\t\tint minLag = (int)(rate / (_Options.MaxRpm / 60.0f * events));\n\t\tint maxLag = (int)(rate / (_Options.MinRpm / 60.0f * events));\n\n\t\tminLag = Math.Max(minLag, 8);\n\t\tmaxLag = Math.Min(maxLag, _Wav.Samples.Length / 4);\n\n\t\tif (maxLag <= minLag)\n\t\t{\n\t\t\t_Report = \"RPM range is too narrow, or the recording is too short to measure.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\tint window = Math.Min(maxLag * 4, _Wav.Samples.Length);\n\n\t\tint start = (int)(_Options.SkipStart * rate);\n\t\tint end = _Wav.Samples.Length - (int)(_Options.SkipEnd * rate);\n\n\t\tstart = Math.Clamp(start, 0, _Wav.Samples.Length - 1);\n\t\tend = Math.Clamp(end, start + window, _Wav.Samples.Length);\n\n\t\tint count = Math.Max(1, _Options.LayerCount);\n\t\tint usable = end - start - window;\n\n\t\tif (usable <= 0)\n\t\t{\n\t\t\t_Report = \"Nothing left to slice after the skip settings.\";\n\n\t\t\treturn layers;\n\t\t}\n\n\t\treport.AppendLine($\"{_Wav.DurationSeconds:0.00}s at {rate}Hz, {events:0.#} firings/rev\");\n\n\t\t// PASS ONE: measure every probe before deciding anything, because the correction below needs neighbours.\n\t\tint[] positions = new int[count];\n\t\tfloat[] firingHz = new float[count];\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\t// Spread the probes across the usable span. The recording's own shape decides what RPM each lands on,\n\t\t\t// which is why the results are rarely evenly spaced \u2014 and why they should not be forced to be.\n\t\t\tpositions[i] = start + (count == 1 ? usable / 2 : usable * i / (count - 1));\n\n\t\t\tfloat period = DetectPeriod(detect, positions[i], window, minLag, maxLag);\n\n\t\t\tfiringHz[i] = period > 0.0f ? rate / period : 0.0f;\n\t\t}\n\n\t\tif (_Options.UseDetectedRpm)\n\t\t{\n\t\t\tint corrected = ResolveOctaves(firingHz, _Options, events);\n\n\t\t\treport.AppendLine($\"labelling from DETECTED pitch ({corrected} octave corrections) \u2014 verify these figures\");\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.AppendLine($\"labelling from the {_Options.StartRpm:0}-{_Options.EndRpm:0}rpm span; detection sets loop length only\");\n\t\t}\n\n\t\tHashSet<int> seen = new();\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\tif (firingHz[i] <= 0.0f)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] no stable pitch found, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Loop length always comes from the MEASURED period \u2014 that is what makes the seam seamless, and it only\n\t\t\t// needs some true period of the signal, which detection supplies reliably.\n\t\t\tfloat period = rate / firingHz[i];\n\n\t\t\t// The label is a different question, and by default a different source. See UseDetectedRpm.\n\t\t\tfloat progress = count == 1 ? 0.5f : (float)i / (count - 1);\n\n\t\t\tint rpm = _Options.UseDetectedRpm\n\t\t\t\t? (int)MathF.Round(firingHz[i] * 60.0f / events)\n\t\t\t\t: (int)MathF.Round(_Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * progress);\n\n\t\t\tif (rpm < _Options.MinRpm || rpm > _Options.MaxRpm)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm out of range, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Two probes can land on the same revs when the run pauses or a gearchange flattens it. Duplicate\n\t\t\t// reference speeds break pair selection, which assumes strictly ascending layers.\n\t\t\tif (!seen.Add(rpm))\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm duplicate, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tSlicedLayer layer = ExtractLoop(_Wav, positions[i], period, _Options.LoopSeconds);\n\n\t\t\tif (layer is null)\n\t\t\t{\n\t\t\t\treport.AppendLine($\" [{i}] {rpm}rpm too close to the end to loop, skipped\");\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tlayer.Rpm = rpm;\n\n\t\t\tlayers.Add(layer);\n\n\t\t\treport.AppendLine($\" {rpm,5}rpm {layer.Cycles} cycles {layer.Samples.Length / (float)rate:0.000}s\");\n\t\t}\n\n\t\tlayers.Sort((a, b) => a.Rpm.CompareTo(b.Rpm));\n\n\t\treport.AppendLine($\"{layers.Count} layers\");\n\n\t\t_Report = report.ToString();\n\n\t\treturn layers;\n\t}\n\n\t/// <summary>\n\t/// Cuts a loop that contains a WHOLE number of firing cycles, then crossfades its seam.\n\t/// </summary>\n\t/// <remarks>\n\t/// A loop holding a fractional cycle restarts mid-bang, and a waveform discontinuity is a click \u2014 heard once\n\t/// per loop, which at a third of a second is three clicks a second and utterly damning. Snapping the length to\n\t/// the detected period is what makes these usable as sustained loops at all.\n\t///\n\t/// Whole cycles still leave a small mismatch, because the engine is speeding up throughout and the end of the\n\t/// loop is fractionally higher than its start. The crossfade takes the material just PAST the loop point and\n\t/// blends it over the head, so the join is a short overlap rather than a step.\n\t/// </remarks>\n\tprivate static SlicedLayer ExtractLoop(WavData _Wav, int _Position, float _Period, float _LoopSeconds)\n\t{\n\t\tint rate = _Wav.SampleRate;\n\t\tint cycles = Math.Max(2, (int)MathF.Round(_LoopSeconds * rate / _Period));\n\t\tint length = (int)MathF.Round(cycles * _Period);\n\t\tint fade = (int)MathF.Round(_Period);\n\n\t\tif (length <= 0 || _Position + length + fade >= _Wav.Samples.Length)\n\t\t\treturn null;\n\n\t\tfloat[] loop = new float[length];\n\n\t\tArray.Copy(_Wav.Samples, _Position, loop, 0, length);\n\n\t\t// Blend the material just past the end over the head. After this the last sample runs into the first\n\t\t// without a step, which is what \"seamless\" actually means.\n\t\tfor (int i = 0; i < fade && i < length; i++)\n\t\t{\n\t\t\tfloat t = (float)i / fade;\n\n\t\t\tloop[i] = loop[i] * t + _Wav.Samples[_Position + length + i] * (1.0f - t);\n\t\t}\n\n\t\tshort[] output = new short[length];\n\n\t\tfor (int i = 0; i < length; i++)\n\t\t\toutput[i] = (short)(Math.Clamp(loop[i], -1.0f, 1.0f) * short.MaxValue);\n\n\t\treturn new SlicedLayer\n\t\t{\n\t\t\tSamples = output,\n\t\t\tSampleRate = rate,\n\t\t\tPeriod = _Period,\n\t\t\tCycles = cycles\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Forces the measured firing rate to rise across the recording, snapping octave errors as it goes.\n\t/// </summary>\n\t/// <remarks>\n\t/// THIS IS WHAT MAKES DETECTION RELIABLE, and it took measuring a known bank to find out. Pitch detection on\n\t/// engine audio is genuinely hard: a four-cylinder repeats every two firings per crank revolution and a V8\n\t/// every four, so the signal is strongly periodic at whole multiples of the firing period. Correlation-based\n\t/// methods lock onto those multiples about as readily as onto the truth, and no threshold separates them \u2014\n\t/// tested against fifteen clips of known speed, the raw detector was exactly right below 2600rpm and exactly\n\t/// four times too slow above it, with nothing in the measurement itself to say which was which.\n\t///\n\t/// The recording answers it. A single pull only ever speeds UP, so a measured drop is impossible and can only\n\t/// be an octave error. Multiplying by the smallest whole number that restores the rise recovers the true rate.\n\t/// On that same bank this took the spread in firings-per-revolution from a factor of four down to 2%.\n\t///\n\t/// The catch is the first probe, which has no predecessor to be judged against: an error there shifts every\n\t/// later value with it. Starting the recording at a steady idle, where detection is easiest, is the defence.\n\t/// </remarks>\n\tprivate static int ResolveOctaves(float[] _FiringHz, SliceOptions _Options, float _EventsPerRev)\n\t{\n\t\tint corrected = 0;\n\t\tint count = _FiringHz.Length;\n\n\t\tfor (int i = 0; i < count; i++)\n\t\t{\n\t\t\tif (_FiringHz[i] <= 0.0f)\n\t\t\t\tcontinue;\n\n\t\t\t// Where the prior says this probe roughly is. Straight-line, which is wrong about real engines \u2014 they\n\t\t\t// pull hardest in the middle \u2014 but only ever used to choose between candidates a whole multiple apart,\n\t\t\t// so being loose is harmless.\n\t\t\tfloat t = count == 1 ? 0.5f : (float)i / (count - 1);\n\t\t\tfloat expectedRpm = _Options.StartRpm + (_Options.EndRpm - _Options.StartRpm) * t;\n\t\t\tfloat expectedHz = MathF.Max(expectedRpm, 1.0f) / 60.0f * _EventsPerRev;\n\n\t\t\tfloat best = _FiringHz[i];\n\t\t\tfloat bestError = MathF.Abs(MathF.Log(best / expectedHz));\n\n\t\t\t// Compared in log space so being twice too fast and half too slow count equally \u2014 in linear terms the\n\t\t\t// high side would always look worse and the search would drift downward.\n\t\t\tforeach (float multiple in OctaveMultiples)\n\t\t\t{\n\t\t\t\tfloat candidate = _FiringHz[i] * multiple;\n\t\t\t\tfloat error = MathF.Abs(MathF.Log(candidate / expectedHz));\n\n\t\t\t\tif (error < bestError)\n\t\t\t\t{\n\t\t\t\t\tbestError = error;\n\t\t\t\t\tbest = candidate;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!best.AlmostEqual(_FiringHz[i]))\n\t\t\t{\n\t\t\t\t_FiringHz[i] = best;\n\t\t\t\tcorrected++;\n\t\t\t}\n\t\t}\n\n\t\treturn corrected;\n\t}\n\n\t/// <summary>\n\t/// Whole-number relationships a firing pattern can hide behind, and their reciprocals.\n\t/// </summary>\n\t/// <remarks>\n\t/// Both directions are needed. Detection can land on a subharmonic \u2014 the crank period rather than the firing\n\t/// period \u2014 or on an upper harmonic, and a search that could only multiply would leave the second kind wrong.\n\t/// </remarks>\n\tprivate static readonly float[] OctaveMultiples =\n\t{\n\t\t2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 8.0f,\n\t\t1.0f / 2.0f, 1.0f / 3.0f, 1.0f / 4.0f, 1.0f / 5.0f, 1.0f / 6.0f, 1.0f / 8.0f\n\t};\n\n\t/// <summary>\n\t/// Finds the firing period in samples, using the normalised square difference function.\n\t/// </summary>\n\t/// <remarks>\n\t/// NSDF rather than plain autocorrelation, and the FIRST strong peak rather than the tallest. Raw correlation\n\t/// grows with the number of terms summed and so favours long lags, which is precisely the wrong bias when the\n\t/// long lags are subharmonics. Normalising by the energy of both windows removes that, and taking the first\n\t/// peak within 10% of the best prefers the shortest period that explains the signal.\n\t///\n\t/// It is still not enough on its own \u2014 see SnapOctaves, which is what actually makes this trustworthy.\n\t/// </remarks>\n\tprivate static float DetectPeriod(float[] _Samples, int _Start, int _Window, int _MinLag, int _MaxLag)\n\t{\n\t\tif (_Start + _Window >= _Samples.Length)\n\t\t\treturn 0.0f;\n\n\t\tfloat[] nsdf = new float[_MaxLag + 2];\n\n\t\tfor (int lag = _MinLag; lag <= _MaxLag; lag++)\n\t\t{\n\t\t\tint overlap = _Window - lag;\n\n\t\t\tif (overlap <= 0)\n\t\t\t\tbreak;\n\n\t\t\tdouble correlation = 0.0;\n\t\t\tdouble energy = 0.0;\n\n\t\t\tfor (int i = 0; i < overlap; i++)\n\t\t\t{\n\t\t\t\tfloat a = _Samples[_Start + i];\n\t\t\t\tfloat b = _Samples[_Start + i + lag];\n\n\t\t\t\tcorrelation += a * b;\n\t\t\t\tenergy += a * a + b * b;\n\t\t\t}\n\n\t\t\tnsdf[lag] = energy > 0.000000000001 ? (float)(2.0 * correlation / energy) : 0.0f;\n\t\t}\n\n\t\t// Key maxima: the high point of each positive run. Ordinary local maxima are far too noisy to use.\n\t\tList<int> peaks = new();\n\n\t\tbool inRun = false;\n\t\tint peak = -1;\n\n\t\tfor (int lag = _MinLag + 1; lag < _MaxLag; lag++)\n\t\t{\n\t\t\tif (!inRun)\n\t\t\t{\n\t\t\t\tif (nsdf[lag] > 0.0f && nsdf[lag] >= nsdf[lag - 1])\n\t\t\t\t{\n\t\t\t\t\tinRun = true;\n\t\t\t\t\tpeak = lag;\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (nsdf[lag] > nsdf[peak])\n\t\t\t\tpeak = lag;\n\n\t\t\tif (nsdf[lag] <= 0.0f)\n\t\t\t{\n\t\t\t\tpeaks.Add(peak);\n\t\t\t\tinRun = false;\n\t\t\t}\n\t\t}\n\n\t\tif (inRun && peak > 0)\n\t\t\tpeaks.Add(peak);\n\n\t\tif (peaks.Count == 0)\n\t\t\treturn 0.0f;\n\n\t\tfloat best = 0.0f;\n\n\t\tforeach (int candidate in peaks)\n\t\t\tbest = MathF.Max(best, nsdf[candidate]);\n\n\t\tif (best <= 0.0f)\n\t\t\treturn 0.0f;\n\n\t\tforeach (int candidate in peaks)\n\t\t{\n\t\t\tif (nsdf[candidate] >= best * 0.9f)\n\t\t\t\treturn Refine(nsdf, candidate);\n\t\t}\n\n\t\treturn 0.0f;\n\t}\n\n\t/// <summary>\n\t/// Parabolic fit through the peak and its neighbours, for sub-sample precision.\n\t/// </summary>\n\t/// <remarks>\n\t/// Worth the few lines: the lag is an integer, so at high revs where the period is short, being one sample out\n\t/// is already a percent or two of error \u2014 tens of RPM on the label, and a permanently mistuned layer.\n\t/// </remarks>\n\tprivate static float Refine(float[] _Scores, int _Lag)\n\t{\n\t\tif (_Lag <= 0 || _Lag + 1 >= _Scores.Length)\n\t\t\treturn _Lag;\n\n\t\tfloat previous = _Scores[_Lag - 1];\n\t\tfloat current = _Scores[_Lag];\n\t\tfloat next = _Scores[_Lag + 1];\n\n\t\tfloat denominator = previous - 2.0f * current + next;\n\n\t\tif (MathF.Abs(denominator) < 0.0000001f)\n\t\t\treturn _Lag;\n\n\t\tfloat shift = 0.5f * (previous - next) / denominator;\n\n\t\treturn _Lag + Math.Clamp(shift, -1.0f, 1.0f);\n\t}\n\n\tprivate static float[] RemoveDc(float[] _Samples)\n\t{\n\t\tfloat mean = 0.0f;\n\n\t\tforeach (float sample in _Samples)\n\t\t\tmean += sample;\n\n\t\tmean /= _Samples.Length;\n\n\t\tfloat[] result = new float[_Samples.Length];\n\n\t\tfor (int i = 0; i < _Samples.Length; i++)\n\t\t\tresult[i] = _Samples[i] - mean;\n\n\t\treturn result;\n\t}\n\n\tprivate static float[] LowPass(float[] _Samples, int _SampleRate, float _Cutoff)\n\t{\n\t\tfloat rc = 1.0f / (MathF.Tau * _Cutoff);\n\t\tfloat dt = 1.0f / _SampleRate;\n\t\tfloat alpha = dt / (rc + dt);\n\n\t\tfloat[] result = new float[_Samples.Length];\n\t\tfloat value = 0.0f;\n\n\t\tfor (int i = 0; i < _Samples.Length; i++)\n\t\t{\n\t\t\tvalue += alpha * (_Samples[i] - value);\n\t\t\tresult[i] = value;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// <summary>Wraps raw samples back up as a 16-bit mono PCM wav.</summary>\n\tpublic static byte[] BuildWav(short[] _Samples, int _SampleRate)\n\t{\n\t\tint dataLength = _Samples.Length * 2;\n\n\t\tusing System.IO.MemoryStream stream = new();\n\t\tusing System.IO.BinaryWriter writer = new(stream);\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"RIFF\"));\n\t\twriter.Write(36 + dataLength);\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"WAVE\"));\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"fmt \"));\n\t\twriter.Write(16);\n\t\twriter.Write((short)1);\n\t\twriter.Write((short)1);\n\t\twriter.Write(_SampleRate);\n\t\twriter.Write(_SampleRate * 2);\n\t\twriter.Write((short)2);\n\t\twriter.Write((short)16);\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\"data\"));\n\t\twriter.Write(dataLength);\n\n\t\tforeach (short sample in _Samples)\n\t\t\twriter.Write(sample);\n\n\t\twriter.Flush();\n\n\t\treturn stream.ToArray();\n\t}\n}\n"
},
{
"Ident": "redsnail.enginebankslicer",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 344082,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Engine Bank Slicer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"enginebankslicer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"redsnail\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"redsnail.enginebankslicer\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-08-22T18:12:47.9771503Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.159.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.159.0\")]"
},
{
"Ident": "redsnail.enginebankslicer",
"Path": "Editor/EngineBankSlicerWindow.cs",
"FileName": "EngineBankSlicerWindow.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 344082,
"Code": "using System;\nusing System.IO;\nusing System.Collections.Generic;\nusing Editor;\n\nnamespace RedSnail.EngineBankSlicer.Editor;\n\n/// <summary>\n/// Turns one recorded acceleration run into a ready-to-use bank of RPM-indexed loops.\n/// </summary>\n/// <remarks>\n/// Point it at a single clean pull from idle to redline and it writes, for every layer it finds:\n///\n/// <list type=\"bullet\">\n/// <item><c><rpm>.wav</c> \u2014 the loop itself, cut to whole firing cycles and crossfaded at the seam</item>\n/// <item><c><rpm>.wav.meta</c> \u2014 with <c>loop: true</c>, which is what makes s&box treat it as sustained</item>\n/// <item><c><rpm>.sound</c> \u2014 a SoundEvent pointing at the compiled vsnd</item>\n/// </list>\n///\n/// The filename is the engine speed measured from the audio, so wiring a layer is copying that number into its\n/// ReferenceRpm. It also prints the prefab JSON for the whole bank, which is usually faster than filling a list\n/// of fifteen entries by hand.\n/// </remarks>\npublic static class EngineBankSlicerWindow\n{\n\t[Menu(\"Editor\", \"Vehicles/Slice Engine Bank...\", \"graphic_eq\")]\n\tpublic static void Open()\n\t{\n\t\tFileDialog dialog = new(null)\n\t\t{\n\t\t\tTitle = \"Choose an acceleration recording (idle to redline, one pull)\",\n\t\t\tDefaultSuffix = \".wav\"\n\t\t};\n\n\t\tdialog.SetNameFilter(\"Audio (*.wav)\");\n\t\tdialog.SetFindExistingFile();\n\t\tdialog.SetModeOpen();\n\n\t\tif (!dialog.Execute())\n\t\t\treturn;\n\n\t\tstring inputPath = dialog.SelectedFile;\n\n\t\tif (string.IsNullOrWhiteSpace(inputPath) || !File.Exists(inputPath))\n\t\t\treturn;\n\n\t\tFileDialog output = new(null)\n\t\t{\n\t\t\tTitle = \"Choose the output folder (inside your project's Assets)\"\n\t\t};\n\n\t\toutput.SetFindDirectory();\n\n\t\tif (!output.Execute())\n\t\t\treturn;\n\n\t\tstring outputPath = output.SelectedFile;\n\n\t\tif (string.IsNullOrWhiteSpace(outputPath))\n\t\t\treturn;\n\n\t\tRun(inputPath, outputPath, new SliceOptions());\n\t}\n\n\t/// <summary>\n\t/// Does the work. Split out from the dialogs so it can be driven from code with explicit options.\n\t/// </summary>\n\tpublic static void Run(string _InputPath, string _OutputPath, SliceOptions _Options)\n\t{\n\t\tWavData wav = EngineBankSlicer.LoadWav(File.ReadAllBytes(_InputPath));\n\n\t\tif (wav is null)\n\t\t{\n\t\t\tLog.Warning($\"[EngineBankSlicer] '{_InputPath}' is not 16-bit PCM wav. Convert it and try again.\");\n\n\t\t\treturn;\n\t\t}\n\n\t\tList<SlicedLayer> layers = EngineBankSlicer.Slice(wav, _Options, out string report);\n\n\t\tLog.Info($\"[EngineBankSlicer] {Path.GetFileName(_InputPath)}\\n{report}\");\n\n\t\tif (layers.Count == 0)\n\t\t{\n\t\t\tLog.Warning(\"[EngineBankSlicer] Nothing usable found. Check the cylinder count first \u2014 it sets the \" +\n\t\t\t \"expected firing rate, and a wrong value moves every detected RPM by the same factor.\");\n\n\t\t\treturn;\n\t\t}\n\n\t\tDirectory.CreateDirectory(_OutputPath);\n\n\t\t// The vsnd path a SoundEvent needs is relative to Assets/ and lowercase, so recover it from wherever the\n\t\t// output folder sits rather than asking for it twice.\n\t\tstring assetRoot = GetAssetRelativePath(_OutputPath);\n\n\t\tif (assetRoot is null)\n\t\t{\n\t\t\tLog.Warning($\"[EngineBankSlicer] '{_OutputPath}' is not inside an Assets folder, so the SoundEvents \" +\n\t\t\t $\"would point nowhere. The wavs were still written; move them and regenerate.\");\n\t\t}\n\n\t\tforeach (SlicedLayer layer in layers)\n\t\t{\n\t\t\tstring name = layer.Rpm.ToString();\n\n\t\t\tFile.WriteAllBytes(Path.Combine(_OutputPath, $\"{name}.wav\"),\n\t\t\t\tEngineBankSlicer.BuildWav(layer.Samples, layer.SampleRate));\n\n\t\t\tFile.WriteAllText(Path.Combine(_OutputPath, $\"{name}.wav.meta\"), MetaJson);\n\n\t\t\tif (assetRoot is not null)\n\t\t\t\tFile.WriteAllText(Path.Combine(_OutputPath, $\"{name}.sound\"), SoundJson($\"{assetRoot}/{name}.vsnd\"));\n\t\t}\n\n\t\tLog.Info($\"[EngineBankSlicer] Wrote {layers.Count} layers to {_OutputPath}\\n\\n\" +\n\t\t $\"Paste into a VehicleNoiseSynthesizer's AccelerationLayers:\\n{BuildPrefabJson(layers, assetRoot)}\");\n\t}\n\n\t/// <summary>\n\t/// Path relative to Assets/, lowercase with forward slashes \u2014 the form asset references take.\n\t/// </summary>\n\tprivate static string GetAssetRelativePath(string _FullPath)\n\t{\n\t\tstring normalised = _FullPath.Replace('\\\\', '/');\n\t\tint index = normalised.LastIndexOf(\"/Assets/\", StringComparison.OrdinalIgnoreCase);\n\n\t\tif (index < 0)\n\t\t\treturn null;\n\n\t\treturn normalised[(index + \"/Assets/\".Length)..].ToLowerInvariant().Trim('/');\n\t}\n\n\t/// <summary>The layer list, ready to paste into a prefab rather than typed in by hand fifteen times.</summary>\n\tprivate static string BuildPrefabJson(List<SlicedLayer> _Layers, string _AssetRoot)\n\t{\n\t\tSystem.Text.StringBuilder builder = new();\n\n\t\tbuilder.AppendLine(\"\\\"AccelerationLayers\\\": [\");\n\n\t\tfor (int i = 0; i < _Layers.Count; i++)\n\t\t{\n\t\t\tSlicedLayer layer = _Layers[i];\n\t\t\tstring comma = i < _Layers.Count - 1 ? \",\" : \"\";\n\n\t\t\tbuilder.AppendLine(\" {\");\n\t\t\tbuilder.AppendLine($\" \\\"Sound\\\": \\\"{_AssetRoot}/{layer.Rpm}.sound\\\",\");\n\t\t\tbuilder.AppendLine($\" \\\"ReferenceRpm\\\": {layer.Rpm},\");\n\t\t\tbuilder.AppendLine(\" \\\"VolumeOffset\\\": 0,\");\n\t\t\tbuilder.AppendLine(\" \\\"PitchOffset\\\": 0,\");\n\t\t\tbuilder.AppendLine(\" \\\"LoPitch\\\": 1,\");\n\t\t\tbuilder.AppendLine(\" \\\"HiPitch\\\": 1\");\n\t\t\tbuilder.AppendLine($\" }}{comma}\");\n\t\t}\n\n\t\tbuilder.AppendLine(\"]\");\n\n\t\treturn builder.ToString();\n\t}\n\n\t/// <summary>loop: true is the entire reason this file is written \u2014 without it the clips are one-shots.</summary>\n\tprivate const string MetaJson =\n\t\t\"\"\"\n\t\t{\n\t\t \"loop\": true,\n\t\t \"start\": 0,\n\t\t \"end\": 0,\n\t\t \"forceMono\": false,\n\t\t \"trimSilence\": false,\n\t\t \"normalize\": false,\n\t\t \"gain\": 0,\n\t\t \"rate\": 44100,\n\t\t \"compress\": false,\n\t\t \"bitrate\": 256\n\t\t}\n\t\t\"\"\";\n\n\tprivate static string SoundJson(string _VsndPath)\n\t{\n\t\treturn $$\"\"\"\n\t\t{\n\t\t \"UI\": false,\n\t\t \"Volume\": \"1\",\n\t\t \"Pitch\": \"1\",\n\t\t \"Decibels\": 70,\n\t\t \"SelectionMode\": \"Random\",\n\t\t \"Sounds\": [\n\t\t \"{{_VsndPath}}\"\n\t\t ],\n\t\t \"OcclusionEnabled\": true,\n\t\t \"Occlusion\": true,\n\t\t \"ReverbEnabled\": true,\n\t\t \"Reflections\": true,\n\t\t \"AirAbsorption\": true,\n\t\t \"Transmission\": true,\n\t\t \"OcclusionRadius\": 64,\n\t\t \"DistanceAttenuation\": true,\n\t\t \"Distance\": 5000,\n\t\t \"__references\": [],\n\t\t \"__version\": 1\n\t\t}\n\t\t\"\"\";\n\t}\n}\n"
}
]
}