Skip to content

Instantly share code, notes, and snippets.

@micahswitzer
Last active June 25, 2023 03:07
Show Gist options
  • Save micahswitzer/5540a7bc3d3e52a554f664e58c891fcc to your computer and use it in GitHub Desktop.
Save micahswitzer/5540a7bc3d3e52a554f664e58c891fcc to your computer and use it in GitHub Desktop.
Convert ADOFAI Levels to MIDI
using Fractions;
using Melanchall.DryWetMidi.Composing;
using Melanchall.DryWetMidi.Core;
using Melanchall.DryWetMidi.Interaction;
using Melanchall.DryWetMidi.Multimedia;
using Melanchall.DryWetMidi.MusicTheory;
using Melanchall.DryWetMidi.Standards;
using Note = Melanchall.DryWetMidi.MusicTheory.Note;
namespace Adofai.Level
{
public class MidiBuilder
{
private readonly PatternBuilder _pattern;
private readonly TempoMapManager _tempo;
public TempoMap TempoMap => _tempo.TempoMap;
public MidiBuilder()
{
_pattern = new PatternBuilder()
// set the default note to a snare
.SetRootNote(Note.Get(GeneralMidiPercussion.AcousticSnare.AsSevenBitNumber()));
_tempo = new TempoMapManager();
}
public void SetTempo(double bpm) => _tempo.SetTempo(0, Tempo.FromBeatsPerMinute(bpm));
// right now we assume that a "note" is always a quarter note
public void AddNote(double duration) => _pattern.Note(Interval.Zero, MusicalTimeSpan.Quarter.Multiply(duration));
public TrackChunk GetTrackChunk()
{
_tempo.SaveChanges();
return _pattern.Build()
.ToTrackChunk(_tempo.TempoMap, GeneralMidi.PercussionChannel);
}
}
public enum OrbitDirection
{
Clockwise,
CounterClockwise,
}
public class Converter
{
// a special value used to indicate that this tile
// shares a pivot point with the previous tile
public const short MIDSPIN = 999;
// these constants are fairly clear, even as
// "magic" numbers in the code. however, I
// think giving them names makes it easier
// to conceptualize the logic that uses them
public const short DEGREES_PER_FULL_ROTATION = 360;
public const short DEGREES_PER_HALF_ROTATION = DEGREES_PER_FULL_ROTATION / 2;
// note that this value will only be half of a rotation if the number of
// planets is two
public const short DEGREES_PER_NOTE = DEGREES_PER_HALF_ROTATION;
public Level Level { get; protected set; }
public Fraction Scale { get; protected set; }
public uint TileIndex { get; protected set; } = 0;
public uint ActionIndex { get; protected set; } = 0;
public OrbitDirection Orbit { get; protected set; } = OrbitDirection.Clockwise;
protected Action CurrentAction => Level.Actions[ActionIndex];
private readonly MidiBuilder _builder;
private bool _isMidspin;
private int _entryAngle;
private int _exitAngle;
public Converter(Level level, Fraction scale)
{
Level = level;
Scale = scale;
_builder = new MidiBuilder();
}
// this will put a positive or negative value into the range
// [0, m)
public static int Mod(int x, int m) => (x % m + m) % m;
protected bool NextTile()
{
var next = TileIndex + 1u;
// next == tile length is valid (see below)
if (next > Level.AngleData.Length)
{
return false;
}
// values that are the same for every kind of tile
TileIndex = next;
// the pivot point moves to the other side of the tile
// thus the 180-degree addition
_entryAngle = (_exitAngle + DEGREES_PER_HALF_ROTATION) % DEGREES_PER_FULL_ROTATION;
// this is the end (portal) tile
// it doesn't have an explicit entry in the level data
// instead it is just assumed to be 180 degrees after
// the previous tile
if (next == Level.AngleData.Length)
{
_exitAngle = _entryAngle + DEGREES_PER_HALF_ROTATION;
_isMidspin = false;
return true;
}
var absoluteAngle = Level.AngleData[next];
_isMidspin = absoluteAngle == MIDSPIN;
// midspins don't go anywhere, so set their exit angle
// to the same angle as their entry angle.
_exitAngle = _isMidspin ? _entryAngle : absoluteAngle;
return true;
}
protected void UpdateActions()
{
// skip over any unused events from previous tiles
// ("floor" means the same thing as "tile")
while (ActionIndex < Level.Actions.Length && CurrentAction.Floor < TileIndex)
{
++ActionIndex;
}
bool didSetSpeed = false;
while (ActionIndex < Level.Actions.Length && CurrentAction.Floor == TileIndex)
{
var action = CurrentAction;
if (action.EventType == Action.ActionType.SetSpeed)
{
// validate the parameters, we don't (yet) support absolute tempo changes
if (didSetSpeed || action.SpeedType != Action.SetSpeedType.Multiplier || action.BpmMultiplier <= 0)
throw new Exception();
// the higher the tempo, the shorter the note durations become
Scale /= new Fraction(action.BpmMultiplier);
// only allow setting the speed once per tile
didSetSpeed = true;
Console.WriteLine($"SetSpeed[{TileIndex}]: BPM Multiplier = {action.BpmMultiplier}");
}
else if (action.EventType == Action.ActionType.Twirl)
{
// invert the orbit direction
Orbit = 1 - Orbit;
Console.WriteLine($"Twirl[{TileIndex}]");
}
++ActionIndex;
}
}
protected Fraction AngleToDuration()
{
// 1 for clockwise (angles are moving from larger to smaller)
// -1 for counter-clockwise (angles are moving from smaller to larger)
var sign = (short)(1 - Orbit) * 2 - 1;
// compute the angle between the two absolute positions
// the sign variable adjusts for the direction of rotation
var angleMoved = Mod((_entryAngle - _exitAngle) * sign, DEGREES_PER_FULL_ROTATION);
// zero can mean one of two things:
// 1. we actually moved 360 degrees (360 mod 360 = 0) or
// 2. we didn't move at all (it's a midspin)
// these two cases can be disambiguated by whether or not this
// tile is a midspin
if (angleMoved == 0)
angleMoved = _isMidspin ? 0 : DEGREES_PER_FULL_ROTATION;
// convert the angle moved into a fraction of a note
// and scale by the current tempo scaling factor
var duration = (angleMoved * Scale) / DEGREES_PER_NOTE;
Console.WriteLine($"Entry: {_entryAngle,4:d}, Exit: {_exitAngle,4:d}, Midspin: {_isMidspin,5}, Moved: {angleMoved,4:d}, Duration: {duration}");
return duration;
}
public void Convert()
{
// set the tempo of the pattern builder so it can space out the notes appropriately
_builder.SetTempo((double)Level.Settings.BPM * Scale.ToDouble());
// apply any actions associated with the start tile
UpdateActions();
// iterate through all of the regular tiles
// (including the end tile)
while (NextTile())
{
// apply any actions/events associated with the tile
UpdateActions();
// compute the note duration as a fraction of a note
var duration = AngleToDuration();
// don't add zero-duration notes to the MIDI pattern
if (duration == Fraction.Zero)
continue;
_builder.AddNote(duration.ToDouble());
}
// now build a MIDI track from the note events
var trackChunk = _builder.GetTrackChunk()!;
// write the MIDI data to a file
/*
var midiFile = new MidiFile(trackChunk);
using var stream = File.OpenWrite("out.mid");
midiFile.Write(stream, MidiFileFormat.SingleTrack);
*/
// play the MIDI data with the first MIDI device!
using var outputDevice = OutputDevice.GetByIndex(0)!;
using var playback = trackChunk.GetPlayback(_builder.TempoMap, outputDevice)!;
playback.Play();
}
}
}
{
"angleData": [0, 0, 0, 0, 0, 90, 180, 270, 0, 0, 0, 0, 0, 0, 0, 90, 999, 0, 90, 999, 0, 0],
"settings":
{
"version": 13 ,
"artist": "",
"specialArtistType": "None",
"artistPermission": "",
"song": "",
"author": "",
"separateCountdownTime": "Enabled",
"previewImage": "",
"previewIcon": "",
"previewIconColor": "003f52",
"previewSongStart": 0,
"previewSongDuration": 10,
"seizureWarning": "Disabled",
"levelDesc": "",
"levelTags": "",
"artistLinks": "",
"difficulty": 1,
"requiredMods": [],
"songFilename": "",
"bpm": 120,
"volume": 100,
"offset": 0,
"pitch": 100,
"hitsound": "Kick",
"hitsoundVolume": 100,
"countdownTicks": 4,
"trackColorType": "Single",
"trackColor": "debb7b",
"secondaryTrackColor": "ffffff",
"trackColorAnimDuration": 2,
"trackColorPulse": "None",
"trackPulseLength": 10,
"trackStyle": "Standard",
"trackTexture": "",
"trackTextureScale": 1,
"trackGlowIntensity": 100,
"trackAnimation": "None",
"beatsAhead": 3,
"trackDisappearAnimation": "None",
"beatsBehind": 4,
"backgroundColor": "000000",
"showDefaultBGIfNoImage": "Enabled",
"showDefaultBGTile": "Enabled",
"defaultBGTileColor": "101121",
"defaultBGShapeType": "Default",
"defaultBGShapeColor": "ffffff",
"bgImage": "",
"bgImageColor": "ffffff",
"parallax": [100, 100],
"bgDisplayMode": "FitToScreen",
"imageSmoothing": "Enabled",
"lockRot": "Disabled",
"loopBG": "Disabled",
"scalingRatio": 100,
"relativeTo": "Player",
"position": [0, 0],
"rotation": 0,
"zoom": 100,
"pulseOnFloor": "Enabled",
"startCamLowVFX": "Disabled",
"bgVideo": "",
"loopVideo": "Disabled",
"vidOffset": 0,
"floorIconOutlines": "Disabled",
"stickToFloors": "Enabled",
"planetEase": "Linear",
"planetEaseParts": 1,
"planetEasePartBehavior": "Mirror",
"customClass": "",
"defaultTextColor": "ffffff",
"defaultTextShadowColor": "00000050",
"congratsText": "",
"perfectText": "",
"legacyFlash": false ,
"legacyCamRelativeTo": false ,
"legacySpriteTiles": false
},
"actions":
[
],
"decorations":
[
]
}
/// Quick and dirty classes for parsing the level format
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Adofai.Level
{
public class Settings
{
public uint Version { get; set; }
public decimal BPM { get; set; }
public int Offset { get; set; }
public string SongFilename { get; set; }
}
public class Action
{
public enum ActionEnabled
{
Enabled,
Disabled,
}
public enum ActionType
{
Unsupported,
SetSpeed,
Twirl,
}
public uint Floor { get; set; }
public ActionType EventType { get; set; } = ActionType.Unsupported;
public ActionEnabled Enabled { get; set; } = ActionEnabled.Enabled;
public enum SetSpeedType
{
Unspecified,
Multiplier,
}
public SetSpeedType SpeedType { get; set; } = SetSpeedType.Unspecified;
public decimal BpmMultiplier { get; set; } = 0;
}
class EventTypeEnumConverter : JsonConverter<Action.ActionType>
{
public override Action.ActionType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (Enum.TryParse(reader.GetString(), out Action.ActionType value))
return value;
return Action.ActionType.Unsupported;
}
public override void Write(Utf8JsonWriter writer, Action.ActionType value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
public class Level
{
public short[] AngleData { get; set; }
public Settings Settings { get; set; }
public Action[] Actions { get; set; }
protected static JsonSerializerOptions Options { get; } =
new JsonSerializerOptions {
AllowTrailingCommas = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
Converters =
{
// this must come first so that it's used
// before of the more generic converter below
new EventTypeEnumConverter(),
new JsonStringEnumConverter(null, false),
}
};
public static Level LoadString(string json)
{
return JsonSerializer.Deserialize<Level>(json, Options)!;
}
public static Level LoadFile(string path)
{
using var file = File.OpenRead(path);
return JsonSerializer.Deserialize<Level>(file, Options)!;
}
public static Level LoadFile(FileInfo file)
{
using var stream = file.OpenRead();
return JsonSerializer.Deserialize<Level>(stream, Options)!;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment