Editor/EngineBankSlicerWindow.cs
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
}
""";
}
}