Last active
June 25, 2023 03:07
-
-
Save micahswitzer/5540a7bc3d3e52a554f664e58c891fcc to your computer and use it in GitHub Desktop.
Convert ADOFAI Levels to MIDI
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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": | |
[ | |
] | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// 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