{"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/// \u003Csummary\u003EA mono, 16-bit view of a loaded wav.\u003C/summary\u003E\npublic sealed class WavData\n{\n\tpublic float[] Samples;\n\tpublic int SampleRate;\n\n\tpublic float DurationSeconds =\u003E SampleRate \u003E 0 ? (float)Samples.Length / SampleRate : 0.0f;\n}\n\n/// \u003Csummary\u003EOne extracted loop, named for the engine speed it was found at.\u003C/summary\u003E\npublic sealed class SlicedLayer\n{\n\tpublic int Rpm;\n\tpublic short[] Samples;\n\tpublic int SampleRate;\n\n\t/// \u003Csummary\u003EDetected firing period in samples, kept for the report.\u003C/summary\u003E\n\tpublic float Period;\n\n\t/// \u003Csummary\u003EWhole engine cycles the loop contains \u2014 never fractional, see the slicer remarks.\u003C/summary\u003E\n\tpublic int Cycles;\n}\n\npublic sealed class SliceOptions\n{\n\tpublic int Cylinders = 6;\n\tpublic bool FourStroke = true;\n\n\t/// \u003Csummary\u003EHow many loops to cut. More gives smoother crossfades and costs more voices at runtime.\u003C/summary\u003E\n\tpublic int LayerCount = 15;\n\n\t/// \u003Csummary\u003ERoughly how long each loop should be. Rounded to whole cycles, so it is a target, not a promise.\u003C/summary\u003E\n\tpublic float LoopSeconds = 0.35f;\n\n\t/// \u003Csummary\u003EDetection bounds. Anything outside is treated as a failed read rather than a real engine speed.\u003C/summary\u003E\n\tpublic float MinRpm = 500.0f;\n\tpublic float MaxRpm = 9000.0f;\n\n\t/// \u003Csummary\u003E\n\t/// Label each clip with the engine speed MEASURED from the audio, rather than interpolated between\n\t/// \u003Csee cref=\u0022StartRpm\u0022/\u003E and \u003Csee cref=\u0022EndRpm\u0022/\u003E.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tpublic bool UseDetectedRpm = false;\n\n\t/// \u003Csummary\u003E\n\t/// Engine speed at the START of the usable audio, and at the END. Normally idle and redline.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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 \u003Csee cref=\u0022UseDetectedRpm\u0022/\u003E 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/// \u003C/remarks\u003E\n\tpublic float StartRpm = 800.0f;\n\tpublic float EndRpm = 7000.0f;\n\n\t/// \u003Csummary\u003ESeconds to ignore at each end \u2014 handy for trimming a key turn or a lift-off.\u003C/summary\u003E\n\tpublic float SkipStart = 0.0f;\n\tpublic float SkipEnd = 0.0f;\n}\n\n/// \u003Csummary\u003E\n/// Cuts one recorded acceleration run \u2014 idle to redline in a single pull \u2014 into an RPM-indexed bank of loops.\n/// \u003C/summary\u003E\n/// \u003Cremarks\u003E\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 \u003Csee cref=\u0022SliceOptions.StartRpm\u0022/\u003E and \u003Csee cref=\u0022SliceOptions.EndRpm\u0022/\u003E, 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\u0027s 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/// \u003Csee cref=\u0022SliceOptions.UseDetectedRpm\u0022/\u003E restores measurement for anyone who wants to try it per-recording.\n/// \u003C/remarks\u003E\npublic static class EngineBankSlicer\n{\n\t/// \u003Csummary\u003ECombustion events per crank revolution.\u003C/summary\u003E\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 \u003C 44)\n\t\t\treturn null;\n\n\t\tif (_Bytes[0] != \u0027R\u0027 || _Bytes[1] != \u0027I\u0027 || _Bytes[2] != \u0027F\u0027 || _Bytes[3] != \u0027F\u0027)\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 \u002B 8 \u003C= _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 \u002B 4);\n\n\t\t\tif (id == \u0022fmt \u0022)\n\t\t\t{\n\t\t\t\tchannels = BitConverter.ToInt16(_Bytes, offset \u002B 10);\n\t\t\t\tsampleRate = BitConverter.ToInt32(_Bytes, offset \u002B 12);\n\t\t\t\tbitsPerSample = BitConverter.ToInt16(_Bytes, offset \u002B 22);\n\t\t\t}\n\t\t\telse if (id == \u0022data\u0022)\n\t\t\t{\n\t\t\t\tdataOffset = offset \u002B 8;\n\t\t\t\tdataLength = size;\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\toffset \u002B= 8 \u002B size \u002B (size \u0026 1);\n\t\t}\n\n\t\tif (dataOffset \u003C 0 || bitsPerSample != 16 || channels \u003C 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 \u003C= 0)\n\t\t\treturn null;\n\n\t\tfloat[] samples = new float[frames];\n\n\t\tfor (int f = 0; f \u003C frames; f\u002B\u002B)\n\t\t{\n\t\t\tfloat sum = 0.0f;\n\n\t\t\tfor (int c = 0; c \u003C channels; c\u002B\u002B)\n\t\t\t\tsum \u002B= BitConverter.ToInt16(_Bytes, dataOffset \u002B ((f * channels \u002B 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/// \u003Csummary\u003E\n\t/// Cuts the bank.\n\t/// \u003C/summary\u003E\n\tpublic static List\u003CSlicedLayer\u003E Slice(WavData _Wav, SliceOptions _Options, out string _Report)\n\t{\n\t\tList\u003CSlicedLayer\u003E layers = new();\n\t\tSystem.Text.StringBuilder report = new();\n\n\t\tif (_Wav?.Samples is not { Length: \u003E 0 })\n\t\t{\n\t\t\t_Report = \u0022No audio loaded.\u0022;\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 \u003C= 0.0f)\n\t\t{\n\t\t\t_Report = \u0022Cylinder count must be at least 1.\u0022;\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 \u003C= minLag)\n\t\t{\n\t\t\t_Report = \u0022RPM range is too narrow, or the recording is too short to measure.\u0022;\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 \u002B 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 \u003C= 0)\n\t\t{\n\t\t\t_Report = \u0022Nothing left to slice after the skip settings.\u0022;\n\n\t\t\treturn layers;\n\t\t}\n\n\t\treport.AppendLine($\u0022{_Wav.DurationSeconds:0.00}s at {rate}Hz, {events:0.#} firings/rev\u0022);\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 \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\t// Spread the probes across the usable span. The recording\u0027s 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 \u002B (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 \u003E 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($\u0022labelling from DETECTED pitch ({corrected} octave corrections) \u2014 verify these figures\u0022);\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.AppendLine($\u0022labelling from the {_Options.StartRpm:0}-{_Options.EndRpm:0}rpm span; detection sets loop length only\u0022);\n\t\t}\n\n\t\tHashSet\u003Cint\u003E seen = new();\n\n\t\tfor (int i = 0; i \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\tif (firingHz[i] \u003C= 0.0f)\n\t\t\t{\n\t\t\t\treport.AppendLine($\u0022  [{i}] no stable pitch found, skipped\u0022);\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 \u002B (_Options.EndRpm - _Options.StartRpm) * progress);\n\n\t\t\tif (rpm \u003C _Options.MinRpm || rpm \u003E _Options.MaxRpm)\n\t\t\t{\n\t\t\t\treport.AppendLine($\u0022  [{i}] {rpm}rpm out of range, skipped\u0022);\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($\u0022  [{i}] {rpm}rpm duplicate, skipped\u0022);\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($\u0022  [{i}] {rpm}rpm too close to the end to loop, skipped\u0022);\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($\u0022  {rpm,5}rpm  {layer.Cycles} cycles  {layer.Samples.Length / (float)rate:0.000}s\u0022);\n\t\t}\n\n\t\tlayers.Sort((a, b) =\u003E a.Rpm.CompareTo(b.Rpm));\n\n\t\treport.AppendLine($\u0022{layers.Count} layers\u0022);\n\n\t\t_Report = report.ToString();\n\n\t\treturn layers;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Cuts a loop that contains a WHOLE number of firing cycles, then crossfades its seam.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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 \u003C= 0 || _Position \u002B length \u002B fade \u003E= _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 \u0022seamless\u0022 actually means.\n\t\tfor (int i = 0; i \u003C fade \u0026\u0026 i \u003C length; i\u002B\u002B)\n\t\t{\n\t\t\tfloat t = (float)i / fade;\n\n\t\t\tloop[i] = loop[i] * t \u002B _Wav.Samples[_Position \u002B length \u002B i] * (1.0f - t);\n\t\t}\n\n\t\tshort[] output = new short[length];\n\n\t\tfor (int i = 0; i \u003C length; i\u002B\u002B)\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/// \u003Csummary\u003E\n\t/// Forces the measured firing rate to rise across the recording, snapping octave errors as it goes.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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 \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\tif (_FiringHz[i] \u003C= 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 \u002B (_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 \u003C 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\u002B\u002B;\n\t\t\t}\n\t\t}\n\n\t\treturn corrected;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Whole-number relationships a firing pattern can hide behind, and their reciprocals.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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/// \u003Csummary\u003E\n\t/// Finds the firing period in samples, using the normalised square difference function.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tprivate static float DetectPeriod(float[] _Samples, int _Start, int _Window, int _MinLag, int _MaxLag)\n\t{\n\t\tif (_Start \u002B _Window \u003E= _Samples.Length)\n\t\t\treturn 0.0f;\n\n\t\tfloat[] nsdf = new float[_MaxLag \u002B 2];\n\n\t\tfor (int lag = _MinLag; lag \u003C= _MaxLag; lag\u002B\u002B)\n\t\t{\n\t\t\tint overlap = _Window - lag;\n\n\t\t\tif (overlap \u003C= 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 \u003C overlap; i\u002B\u002B)\n\t\t\t{\n\t\t\t\tfloat a = _Samples[_Start \u002B i];\n\t\t\t\tfloat b = _Samples[_Start \u002B i \u002B lag];\n\n\t\t\t\tcorrelation \u002B= a * b;\n\t\t\t\tenergy \u002B= a * a \u002B b * b;\n\t\t\t}\n\n\t\t\tnsdf[lag] = energy \u003E 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\u003Cint\u003E peaks = new();\n\n\t\tbool inRun = false;\n\t\tint peak = -1;\n\n\t\tfor (int lag = _MinLag \u002B 1; lag \u003C _MaxLag; lag\u002B\u002B)\n\t\t{\n\t\t\tif (!inRun)\n\t\t\t{\n\t\t\t\tif (nsdf[lag] \u003E 0.0f \u0026\u0026 nsdf[lag] \u003E= 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] \u003E nsdf[peak])\n\t\t\t\tpeak = lag;\n\n\t\t\tif (nsdf[lag] \u003C= 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 \u0026\u0026 peak \u003E 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 \u003C= 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] \u003E= 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/// \u003Csummary\u003E\n\t/// Parabolic fit through the peak and its neighbours, for sub-sample precision.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tprivate static float Refine(float[] _Scores, int _Lag)\n\t{\n\t\tif (_Lag \u003C= 0 || _Lag \u002B 1 \u003E= _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 \u002B 1];\n\n\t\tfloat denominator = previous - 2.0f * current \u002B next;\n\n\t\tif (MathF.Abs(denominator) \u003C 0.0000001f)\n\t\t\treturn _Lag;\n\n\t\tfloat shift = 0.5f * (previous - next) / denominator;\n\n\t\treturn _Lag \u002B 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 \u002B= sample;\n\n\t\tmean /= _Samples.Length;\n\n\t\tfloat[] result = new float[_Samples.Length];\n\n\t\tfor (int i = 0; i \u003C _Samples.Length; i\u002B\u002B)\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 \u002B dt);\n\n\t\tfloat[] result = new float[_Samples.Length];\n\t\tfloat value = 0.0f;\n\n\t\tfor (int i = 0; i \u003C _Samples.Length; i\u002B\u002B)\n\t\t{\n\t\t\tvalue \u002B= alpha * (_Samples[i] - value);\n\t\t\tresult[i] = value;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003EWraps raw samples back up as a 16-bit mono PCM wav.\u003C/summary\u003E\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(\u0022RIFF\u0022));\n\t\twriter.Write(36 \u002B dataLength);\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\u0022WAVE\u0022));\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\u0022fmt \u0022));\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(\u0022data\u0022));\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/// \u003Csummary\u003EA mono, 16-bit view of a loaded wav.\u003C/summary\u003E\npublic sealed class WavData\n{\n\tpublic float[] Samples;\n\tpublic int SampleRate;\n\n\tpublic float DurationSeconds =\u003E SampleRate \u003E 0 ? (float)Samples.Length / SampleRate : 0.0f;\n}\n\n/// \u003Csummary\u003EOne extracted loop, named for the engine speed it was found at.\u003C/summary\u003E\npublic sealed class SlicedLayer\n{\n\tpublic int Rpm;\n\tpublic short[] Samples;\n\tpublic int SampleRate;\n\n\t/// \u003Csummary\u003EDetected firing period in samples, kept for the report.\u003C/summary\u003E\n\tpublic float Period;\n\n\t/// \u003Csummary\u003EWhole engine cycles the loop contains \u2014 never fractional, see the slicer remarks.\u003C/summary\u003E\n\tpublic int Cycles;\n}\n\npublic sealed class SliceOptions\n{\n\tpublic int Cylinders = 6;\n\tpublic bool FourStroke = true;\n\n\t/// \u003Csummary\u003EHow many loops to cut. More gives smoother crossfades and costs more voices at runtime.\u003C/summary\u003E\n\tpublic int LayerCount = 15;\n\n\t/// \u003Csummary\u003ERoughly how long each loop should be. Rounded to whole cycles, so it is a target, not a promise.\u003C/summary\u003E\n\tpublic float LoopSeconds = 0.35f;\n\n\t/// \u003Csummary\u003EDetection bounds. Anything outside is treated as a failed read rather than a real engine speed.\u003C/summary\u003E\n\tpublic float MinRpm = 500.0f;\n\tpublic float MaxRpm = 9000.0f;\n\n\t/// \u003Csummary\u003E\n\t/// Label each clip with the engine speed MEASURED from the audio, rather than interpolated between\n\t/// \u003Csee cref=\u0022StartRpm\u0022/\u003E and \u003Csee cref=\u0022EndRpm\u0022/\u003E.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tpublic bool UseDetectedRpm = false;\n\n\t/// \u003Csummary\u003E\n\t/// Engine speed at the START of the usable audio, and at the END. Normally idle and redline.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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 \u003Csee cref=\u0022UseDetectedRpm\u0022/\u003E 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/// \u003C/remarks\u003E\n\tpublic float StartRpm = 800.0f;\n\tpublic float EndRpm = 7000.0f;\n\n\t/// \u003Csummary\u003ESeconds to ignore at each end \u2014 handy for trimming a key turn or a lift-off.\u003C/summary\u003E\n\tpublic float SkipStart = 0.0f;\n\tpublic float SkipEnd = 0.0f;\n}\n\n/// \u003Csummary\u003E\n/// Cuts one recorded acceleration run \u2014 idle to redline in a single pull \u2014 into an RPM-indexed bank of loops.\n/// \u003C/summary\u003E\n/// \u003Cremarks\u003E\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 \u003Csee cref=\u0022SliceOptions.StartRpm\u0022/\u003E and \u003Csee cref=\u0022SliceOptions.EndRpm\u0022/\u003E, 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\u0027s 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/// \u003Csee cref=\u0022SliceOptions.UseDetectedRpm\u0022/\u003E restores measurement for anyone who wants to try it per-recording.\n/// \u003C/remarks\u003E\npublic static class EngineBankSlicer\n{\n\t/// \u003Csummary\u003ECombustion events per crank revolution.\u003C/summary\u003E\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 \u003C 44)\n\t\t\treturn null;\n\n\t\tif (_Bytes[0] != \u0027R\u0027 || _Bytes[1] != \u0027I\u0027 || _Bytes[2] != \u0027F\u0027 || _Bytes[3] != \u0027F\u0027)\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 \u002B 8 \u003C= _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 \u002B 4);\n\n\t\t\tif (id == \u0022fmt \u0022)\n\t\t\t{\n\t\t\t\tchannels = BitConverter.ToInt16(_Bytes, offset \u002B 10);\n\t\t\t\tsampleRate = BitConverter.ToInt32(_Bytes, offset \u002B 12);\n\t\t\t\tbitsPerSample = BitConverter.ToInt16(_Bytes, offset \u002B 22);\n\t\t\t}\n\t\t\telse if (id == \u0022data\u0022)\n\t\t\t{\n\t\t\t\tdataOffset = offset \u002B 8;\n\t\t\t\tdataLength = size;\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\toffset \u002B= 8 \u002B size \u002B (size \u0026 1);\n\t\t}\n\n\t\tif (dataOffset \u003C 0 || bitsPerSample != 16 || channels \u003C 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 \u003C= 0)\n\t\t\treturn null;\n\n\t\tfloat[] samples = new float[frames];\n\n\t\tfor (int f = 0; f \u003C frames; f\u002B\u002B)\n\t\t{\n\t\t\tfloat sum = 0.0f;\n\n\t\t\tfor (int c = 0; c \u003C channels; c\u002B\u002B)\n\t\t\t\tsum \u002B= BitConverter.ToInt16(_Bytes, dataOffset \u002B ((f * channels \u002B 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/// \u003Csummary\u003E\n\t/// Cuts the bank.\n\t/// \u003C/summary\u003E\n\tpublic static List\u003CSlicedLayer\u003E Slice(WavData _Wav, SliceOptions _Options, out string _Report)\n\t{\n\t\tList\u003CSlicedLayer\u003E layers = new();\n\t\tSystem.Text.StringBuilder report = new();\n\n\t\tif (_Wav?.Samples is not { Length: \u003E 0 })\n\t\t{\n\t\t\t_Report = \u0022No audio loaded.\u0022;\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 \u003C= 0.0f)\n\t\t{\n\t\t\t_Report = \u0022Cylinder count must be at least 1.\u0022;\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 \u003C= minLag)\n\t\t{\n\t\t\t_Report = \u0022RPM range is too narrow, or the recording is too short to measure.\u0022;\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 \u002B 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 \u003C= 0)\n\t\t{\n\t\t\t_Report = \u0022Nothing left to slice after the skip settings.\u0022;\n\n\t\t\treturn layers;\n\t\t}\n\n\t\treport.AppendLine($\u0022{_Wav.DurationSeconds:0.00}s at {rate}Hz, {events:0.#} firings/rev\u0022);\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 \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\t// Spread the probes across the usable span. The recording\u0027s 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 \u002B (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 \u003E 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($\u0022labelling from DETECTED pitch ({corrected} octave corrections) \u2014 verify these figures\u0022);\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.AppendLine($\u0022labelling from the {_Options.StartRpm:0}-{_Options.EndRpm:0}rpm span; detection sets loop length only\u0022);\n\t\t}\n\n\t\tHashSet\u003Cint\u003E seen = new();\n\n\t\tfor (int i = 0; i \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\tif (firingHz[i] \u003C= 0.0f)\n\t\t\t{\n\t\t\t\treport.AppendLine($\u0022  [{i}] no stable pitch found, skipped\u0022);\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 \u002B (_Options.EndRpm - _Options.StartRpm) * progress);\n\n\t\t\tif (rpm \u003C _Options.MinRpm || rpm \u003E _Options.MaxRpm)\n\t\t\t{\n\t\t\t\treport.AppendLine($\u0022  [{i}] {rpm}rpm out of range, skipped\u0022);\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($\u0022  [{i}] {rpm}rpm duplicate, skipped\u0022);\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($\u0022  [{i}] {rpm}rpm too close to the end to loop, skipped\u0022);\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($\u0022  {rpm,5}rpm  {layer.Cycles} cycles  {layer.Samples.Length / (float)rate:0.000}s\u0022);\n\t\t}\n\n\t\tlayers.Sort((a, b) =\u003E a.Rpm.CompareTo(b.Rpm));\n\n\t\treport.AppendLine($\u0022{layers.Count} layers\u0022);\n\n\t\t_Report = report.ToString();\n\n\t\treturn layers;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Cuts a loop that contains a WHOLE number of firing cycles, then crossfades its seam.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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 \u003C= 0 || _Position \u002B length \u002B fade \u003E= _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 \u0022seamless\u0022 actually means.\n\t\tfor (int i = 0; i \u003C fade \u0026\u0026 i \u003C length; i\u002B\u002B)\n\t\t{\n\t\t\tfloat t = (float)i / fade;\n\n\t\t\tloop[i] = loop[i] * t \u002B _Wav.Samples[_Position \u002B length \u002B i] * (1.0f - t);\n\t\t}\n\n\t\tshort[] output = new short[length];\n\n\t\tfor (int i = 0; i \u003C length; i\u002B\u002B)\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/// \u003Csummary\u003E\n\t/// Forces the measured firing rate to rise across the recording, snapping octave errors as it goes.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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 \u003C count; i\u002B\u002B)\n\t\t{\n\t\t\tif (_FiringHz[i] \u003C= 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 \u002B (_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 \u003C 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\u002B\u002B;\n\t\t\t}\n\t\t}\n\n\t\treturn corrected;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Whole-number relationships a firing pattern can hide behind, and their reciprocals.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\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/// \u003Csummary\u003E\n\t/// Finds the firing period in samples, using the normalised square difference function.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tprivate static float DetectPeriod(float[] _Samples, int _Start, int _Window, int _MinLag, int _MaxLag)\n\t{\n\t\tif (_Start \u002B _Window \u003E= _Samples.Length)\n\t\t\treturn 0.0f;\n\n\t\tfloat[] nsdf = new float[_MaxLag \u002B 2];\n\n\t\tfor (int lag = _MinLag; lag \u003C= _MaxLag; lag\u002B\u002B)\n\t\t{\n\t\t\tint overlap = _Window - lag;\n\n\t\t\tif (overlap \u003C= 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 \u003C overlap; i\u002B\u002B)\n\t\t\t{\n\t\t\t\tfloat a = _Samples[_Start \u002B i];\n\t\t\t\tfloat b = _Samples[_Start \u002B i \u002B lag];\n\n\t\t\t\tcorrelation \u002B= a * b;\n\t\t\t\tenergy \u002B= a * a \u002B b * b;\n\t\t\t}\n\n\t\t\tnsdf[lag] = energy \u003E 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\u003Cint\u003E peaks = new();\n\n\t\tbool inRun = false;\n\t\tint peak = -1;\n\n\t\tfor (int lag = _MinLag \u002B 1; lag \u003C _MaxLag; lag\u002B\u002B)\n\t\t{\n\t\t\tif (!inRun)\n\t\t\t{\n\t\t\t\tif (nsdf[lag] \u003E 0.0f \u0026\u0026 nsdf[lag] \u003E= 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] \u003E nsdf[peak])\n\t\t\t\tpeak = lag;\n\n\t\t\tif (nsdf[lag] \u003C= 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 \u0026\u0026 peak \u003E 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 \u003C= 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] \u003E= 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/// \u003Csummary\u003E\n\t/// Parabolic fit through the peak and its neighbours, for sub-sample precision.\n\t/// \u003C/summary\u003E\n\t/// \u003Cremarks\u003E\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/// \u003C/remarks\u003E\n\tprivate static float Refine(float[] _Scores, int _Lag)\n\t{\n\t\tif (_Lag \u003C= 0 || _Lag \u002B 1 \u003E= _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 \u002B 1];\n\n\t\tfloat denominator = previous - 2.0f * current \u002B next;\n\n\t\tif (MathF.Abs(denominator) \u003C 0.0000001f)\n\t\t\treturn _Lag;\n\n\t\tfloat shift = 0.5f * (previous - next) / denominator;\n\n\t\treturn _Lag \u002B 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 \u002B= sample;\n\n\t\tmean /= _Samples.Length;\n\n\t\tfloat[] result = new float[_Samples.Length];\n\n\t\tfor (int i = 0; i \u003C _Samples.Length; i\u002B\u002B)\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 \u002B dt);\n\n\t\tfloat[] result = new float[_Samples.Length];\n\t\tfloat value = 0.0f;\n\n\t\tfor (int i = 0; i \u003C _Samples.Length; i\u002B\u002B)\n\t\t{\n\t\t\tvalue \u002B= alpha * (_Samples[i] - value);\n\t\t\tresult[i] = value;\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003EWraps raw samples back up as a 16-bit mono PCM wav.\u003C/summary\u003E\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(\u0022RIFF\u0022));\n\t\twriter.Write(36 \u002B dataLength);\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\u0022WAVE\u0022));\n\n\t\twriter.Write(System.Text.Encoding.ASCII.GetBytes(\u0022fmt \u0022));\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(\u0022data\u0022));\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( \u0022AddonTitle\u0022, \u0022Engine Bank Slicer\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonIdent\u0022, \u0022enginebankslicer\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022OrgIdent\u0022, \u0022redsnail\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022Ident\u0022, \u0022redsnail.enginebankslicer\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineVersion\u0022, \u002228\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineMinorVersion\u0022, \u00221\u0022 )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \u0022.NETCoreApp,Version=v9.0\u0022, FrameworkDisplayName = \u0022.NET 9.0\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022CompileTime\u0022, \u00222026-08-22T18:12:47.9771503Z\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\u00220.0.159.0\u0022)]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\u00220.0.159.0\u0022)]"},{"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/// \u003Csummary\u003E\n/// Turns one recorded acceleration run into a ready-to-use bank of RPM-indexed loops.\n/// \u003C/summary\u003E\n/// \u003Cremarks\u003E\n/// Point it at a single clean pull from idle to redline and it writes, for every layer it finds:\n///\n/// \u003Clist type=\u0022bullet\u0022\u003E\n/// \u003Citem\u003E\u003Cc\u003E\u0026lt;rpm\u0026gt;.wav\u003C/c\u003E \u2014 the loop itself, cut to whole firing cycles and crossfaded at the seam\u003C/item\u003E\n/// \u003Citem\u003E\u003Cc\u003E\u0026lt;rpm\u0026gt;.wav.meta\u003C/c\u003E \u2014 with \u003Cc\u003Eloop: true\u003C/c\u003E, which is what makes s\u0026amp;box treat it as sustained\u003C/item\u003E\n/// \u003Citem\u003E\u003Cc\u003E\u0026lt;rpm\u0026gt;.sound\u003C/c\u003E \u2014 a SoundEvent pointing at the compiled vsnd\u003C/item\u003E\n/// \u003C/list\u003E\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/// \u003C/remarks\u003E\npublic static class EngineBankSlicerWindow\n{\n\t[Menu(\u0022Editor\u0022, \u0022Vehicles/Slice Engine Bank...\u0022, \u0022graphic_eq\u0022)]\n\tpublic static void Open()\n\t{\n\t\tFileDialog dialog = new(null)\n\t\t{\n\t\t\tTitle = \u0022Choose an acceleration recording (idle to redline, one pull)\u0022,\n\t\t\tDefaultSuffix = \u0022.wav\u0022\n\t\t};\n\n\t\tdialog.SetNameFilter(\u0022Audio (*.wav)\u0022);\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 = \u0022Choose the output folder (inside your project\u0027s Assets)\u0022\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/// \u003Csummary\u003E\n\t/// Does the work. Split out from the dialogs so it can be driven from code with explicit options.\n\t/// \u003C/summary\u003E\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($\u0022[EngineBankSlicer] \u0027{_InputPath}\u0027 is not 16-bit PCM wav. Convert it and try again.\u0022);\n\n\t\t\treturn;\n\t\t}\n\n\t\tList\u003CSlicedLayer\u003E layers = EngineBankSlicer.Slice(wav, _Options, out string report);\n\n\t\tLog.Info($\u0022[EngineBankSlicer] {Path.GetFileName(_InputPath)}\\n{report}\u0022);\n\n\t\tif (layers.Count == 0)\n\t\t{\n\t\t\tLog.Warning(\u0022[EngineBankSlicer] Nothing usable found. Check the cylinder count first \u2014 it sets the \u0022 \u002B\n\t\t\t            \u0022expected firing rate, and a wrong value moves every detected RPM by the same factor.\u0022);\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($\u0022[EngineBankSlicer] \u0027{_OutputPath}\u0027 is not inside an Assets folder, so the SoundEvents \u0022 \u002B\n\t\t\t            $\u0022would point nowhere. The wavs were still written; move them and regenerate.\u0022);\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, $\u0022{name}.wav\u0022),\n\t\t\t\tEngineBankSlicer.BuildWav(layer.Samples, layer.SampleRate));\n\n\t\t\tFile.WriteAllText(Path.Combine(_OutputPath, $\u0022{name}.wav.meta\u0022), MetaJson);\n\n\t\t\tif (assetRoot is not null)\n\t\t\t\tFile.WriteAllText(Path.Combine(_OutputPath, $\u0022{name}.sound\u0022), SoundJson($\u0022{assetRoot}/{name}.vsnd\u0022));\n\t\t}\n\n\t\tLog.Info($\u0022[EngineBankSlicer] Wrote {layers.Count} layers to {_OutputPath}\\n\\n\u0022 \u002B\n\t\t         $\u0022Paste into a VehicleNoiseSynthesizer\u0027s AccelerationLayers:\\n{BuildPrefabJson(layers, assetRoot)}\u0022);\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Path relative to Assets/, lowercase with forward slashes \u2014 the form asset references take.\n\t/// \u003C/summary\u003E\n\tprivate static string GetAssetRelativePath(string _FullPath)\n\t{\n\t\tstring normalised = _FullPath.Replace(\u0027\\\\\u0027, \u0027/\u0027);\n\t\tint index = normalised.LastIndexOf(\u0022/Assets/\u0022, StringComparison.OrdinalIgnoreCase);\n\n\t\tif (index \u003C 0)\n\t\t\treturn null;\n\n\t\treturn normalised[(index \u002B \u0022/Assets/\u0022.Length)..].ToLowerInvariant().Trim(\u0027/\u0027);\n\t}\n\n\t/// \u003Csummary\u003EThe layer list, ready to paste into a prefab rather than typed in by hand fifteen times.\u003C/summary\u003E\n\tprivate static string BuildPrefabJson(List\u003CSlicedLayer\u003E _Layers, string _AssetRoot)\n\t{\n\t\tSystem.Text.StringBuilder builder = new();\n\n\t\tbuilder.AppendLine(\u0022\\\u0022AccelerationLayers\\\u0022: [\u0022);\n\n\t\tfor (int i = 0; i \u003C _Layers.Count; i\u002B\u002B)\n\t\t{\n\t\t\tSlicedLayer layer = _Layers[i];\n\t\t\tstring comma = i \u003C _Layers.Count - 1 ? \u0022,\u0022 : \u0022\u0022;\n\n\t\t\tbuilder.AppendLine(\u0022  {\u0022);\n\t\t\tbuilder.AppendLine($\u0022    \\\u0022Sound\\\u0022: \\\u0022{_AssetRoot}/{layer.Rpm}.sound\\\u0022,\u0022);\n\t\t\tbuilder.AppendLine($\u0022    \\\u0022ReferenceRpm\\\u0022: {layer.Rpm},\u0022);\n\t\t\tbuilder.AppendLine(\u0022    \\\u0022VolumeOffset\\\u0022: 0,\u0022);\n\t\t\tbuilder.AppendLine(\u0022    \\\u0022PitchOffset\\\u0022: 0,\u0022);\n\t\t\tbuilder.AppendLine(\u0022    \\\u0022LoPitch\\\u0022: 1,\u0022);\n\t\t\tbuilder.AppendLine(\u0022    \\\u0022HiPitch\\\u0022: 1\u0022);\n\t\t\tbuilder.AppendLine($\u0022  }}{comma}\u0022);\n\t\t}\n\n\t\tbuilder.AppendLine(\u0022]\u0022);\n\n\t\treturn builder.ToString();\n\t}\n\n\t/// \u003Csummary\u003Eloop: true is the entire reason this file is written \u2014 without it the clips are one-shots.\u003C/summary\u003E\n\tprivate const string MetaJson =\n\t\t\u0022\u0022\u0022\n\t\t{\n\t\t  \u0022loop\u0022: true,\n\t\t  \u0022start\u0022: 0,\n\t\t  \u0022end\u0022: 0,\n\t\t  \u0022forceMono\u0022: false,\n\t\t  \u0022trimSilence\u0022: false,\n\t\t  \u0022normalize\u0022: false,\n\t\t  \u0022gain\u0022: 0,\n\t\t  \u0022rate\u0022: 44100,\n\t\t  \u0022compress\u0022: false,\n\t\t  \u0022bitrate\u0022: 256\n\t\t}\n\t\t\u0022\u0022\u0022;\n\n\tprivate static string SoundJson(string _VsndPath)\n\t{\n\t\treturn $$\u0022\u0022\u0022\n\t\t{\n\t\t  \u0022UI\u0022: false,\n\t\t  \u0022Volume\u0022: \u00221\u0022,\n\t\t  \u0022Pitch\u0022: \u00221\u0022,\n\t\t  \u0022Decibels\u0022: 70,\n\t\t  \u0022SelectionMode\u0022: \u0022Random\u0022,\n\t\t  \u0022Sounds\u0022: [\n\t\t    \u0022{{_VsndPath}}\u0022\n\t\t  ],\n\t\t  \u0022OcclusionEnabled\u0022: true,\n\t\t  \u0022Occlusion\u0022: true,\n\t\t  \u0022ReverbEnabled\u0022: true,\n\t\t  \u0022Reflections\u0022: true,\n\t\t  \u0022AirAbsorption\u0022: true,\n\t\t  \u0022Transmission\u0022: true,\n\t\t  \u0022OcclusionRadius\u0022: 64,\n\t\t  \u0022DistanceAttenuation\u0022: true,\n\t\t  \u0022Distance\u0022: 5000,\n\t\t  \u0022__references\u0022: [],\n\t\t  \u0022__version\u0022: 1\n\t\t}\n\t\t\u0022\u0022\u0022;\n\t}\n}\n"}]}