From 73695b20e742bd5d8cba0cc3d9eb09a7d14c71b3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Tue, 13 Jun 2023 09:01:31 -0600 Subject: [PATCH] Rework unit tests again to allow testing all event types, and to test all instrument tracks (#20) * Make SongObjects cloneable and ToString-able * Rework parse behavior tests again to use ChartObjects and MoonSongs as their sources .mid generation was rewritten along the way * Generate and verify all instruments, not just specific ones * Fix out-of-bounds error when retrieving Harmony 3 charts Also removed the try/catch from GetChart so issues like this aren't masked away * Fix .mid time signature denominators not being parsed correctly * Handle special phrases and local events in chart generation * Parse in tremolo/trill lanes on Pro Guitar * Don't automate setting of event tick positions --- .../Parsing/ParseBehaviorTests.Chart.cs | 197 ++++--- .../Parsing/ParseBehaviorTests.Midi.cs | 281 +++++++--- .../Parsing/ParseBehaviorTests.cs | 484 ++++++++++++------ .../Parsing/SongObjectComparer.cs | 53 ++ .../MoonscraperChartParser/Events/BPM.cs | 16 + .../Events/ChartEvent.cs | 16 + .../Events/ChartObject.cs | 5 + .../MoonscraperChartParser/Events/Event.cs | 15 + .../MoonscraperChartParser/Events/MoonNote.cs | 16 + .../MoonscraperChartParser/Events/Section.cs | 15 + .../Events/SongObject.cs | 9 + .../Events/SpecialPhrase.cs | 16 + .../Events/SyncTrack.cs | 5 + .../Events/TimeSignature.cs | 15 + .../IO/Midi/MidReader.cs | 8 +- YARG.Core/MoonscraperChartParser/MoonSong.cs | 14 +- 16 files changed, 867 insertions(+), 298 deletions(-) create mode 100644 YARG.Core.UnitTests/Parsing/SongObjectComparer.cs diff --git a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Chart.cs b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Chart.cs index c9a32581c..78d1db93e 100644 --- a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Chart.cs +++ b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Chart.cs @@ -58,13 +58,103 @@ public class ChartParseBehaviorTests { GameMode.GHLGuitar, GhlGuitarNoteLookup }, }; - private static void GenerateSection(StringBuilder builder, List data, MoonInstrument instrument, Difficulty difficulty) + private static readonly Dictionary SpecialPhraseLookup = new() { - string instrumentName = InstrumentToNameLookup[instrument]; + { SpecialPhrase.Type.Starpower, PHRASE_STARPOWER }, + { SpecialPhrase.Type.Versus_Player1, PHRASE_VERSUS_PLAYER_1 }, + { SpecialPhrase.Type.Versus_Player2, PHRASE_VERSUS_PLAYER_2 }, + { SpecialPhrase.Type.TremoloLane, PHRASE_TREMOLO_LANE }, + { SpecialPhrase.Type.TrillLane, PHRASE_TRILL_LANE }, + { SpecialPhrase.Type.ProDrums_Activation, PHRASE_DRUM_FILL }, + }; + + private static void GenerateSongSection(MoonSong sourceSong, StringBuilder builder) + { + builder.Append($"{SECTION_SONG}\n{{\n"); + builder.Append($" Resolution = {sourceSong.resolution}"); + builder.Append("}\n"); + } + + private static void GenerateSyncSection(MoonSong sourceSong, StringBuilder builder) + { + builder.Append($"{SECTION_SYNC_TRACK}\n{{\n"); + foreach (var sync in sourceSong.syncTrack) + { + switch (sync) + { + case BPM bpm: + builder.Append($" {bpm.tick} = B {bpm.value}"); + break; + case TimeSignature ts: + builder.Append($" {ts.tick} = TS {ts.numerator} {(int)Math.Log2(ts.denominator)}"); + break; + } + } + builder.Append("}\n"); + } + + private static void GenerateEventsSection(MoonSong sourceSong, StringBuilder builder) + { + builder.Append($"{SECTION_EVENTS}\n{{\n"); + foreach (var text in sourceSong.eventsAndSections) + { + builder.Append($" {text.tick} = E \"{text.title}\""); + } + builder.Append("}\n"); + } + + private static void GenerateInstrumentSection(MoonSong sourceSong, StringBuilder builder, MoonInstrument instrument, Difficulty difficulty) + { + // Skip unsupported instruments var gameMode = MoonSong.InstumentToChartGameMode(instrument); + if (!InstrumentToNoteLookupLookup.ContainsKey(gameMode)) + return; + + var chart = sourceSong.GetChart(instrument, difficulty); + + string instrumentName = InstrumentToNameLookup[instrument]; string difficultyName = DifficultyToNameLookup[difficulty]; builder.Append($"[{difficultyName}{instrumentName}]\n{{\n"); + List eventsToRemove = new(); + foreach (var chartObj in chart.chartObjects) + { + switch (chartObj) + { + case MoonNote note: + AppendNote(builder, note); + break; + case SpecialPhrase phrase: + // Drums-only phrases + if (gameMode is not GameMode.Drums && phrase.type is SpecialPhrase.Type.TremoloLane or + SpecialPhrase.Type.TrillLane or SpecialPhrase.Type.ProDrums_Activation) + { + eventsToRemove.Add(chartObj); + continue; + } + int phraseNumber = SpecialPhraseLookup[phrase.type]; + builder.Append($" {phrase.tick} = S {phraseNumber} {phrase.length}\n"); + break; + case ChartEvent text: + builder.Append($" {text.tick} = E {text.eventName}\n"); + break; + } + } + + foreach (var chartObj in eventsToRemove) + { + chart.Remove(chartObj); + } + + builder.Append("}\n"); + } + + private static void AppendNote(StringBuilder builder, MoonNote note) + { + uint tick = note.tick; + var flags = note.flags; + var gameMode = note.gameMode; + bool canForce = gameMode is GameMode.Guitar or GameMode.GHLGuitar; bool canTap = gameMode is GameMode.Guitar or GameMode.GHLGuitar; bool canCymbal = gameMode is GameMode.Drums; @@ -72,61 +162,45 @@ private static void GenerateSection(StringBuilder builder, List data, bool canDynamics = gameMode is GameMode.Drums; var noteLookup = InstrumentToNoteLookupLookup[gameMode]; - for (int index = 0; index < data.Count; index++) - { - uint tick = RESOLUTION * (uint)index; - var note = data[index]; - var flags = note.flags; - - // Not technically necessary, but might as well lol - int rawNote = gameMode switch { - GameMode.Guitar => (int)note.guitarFret, - GameMode.GHLGuitar => (int)note.ghliveGuitarFret, - GameMode.ProGuitar => throw new NotSupportedException(".chart does not support Pro Guitar!"), - GameMode.Drums => (int)note.drumPad, - _ => note.rawNote - }; - - int chartNumber = noteLookup[rawNote]; - if (canDoubleKick && (flags & Flags.DoubleKick) != 0) - chartNumber = NOTE_OFFSET_INSTRUMENT_PLUS; - - builder.Append($" {tick} = N {chartNumber} {note.length}\n"); - if (canForce && (flags & Flags.Forced) != 0) - builder.Append($" {tick} = N 5 0\n"); - if (canTap && (flags & Flags.Tap) != 0) - builder.Append($" {tick} = N 6 0\n"); - if (canCymbal && (flags & Flags.ProDrums_Cymbal) != 0) - builder.Append($" {tick} = N {NOTE_OFFSET_PRO_DRUMS + chartNumber} 0\n"); - if (canDynamics && (flags & Flags.ProDrums_Accent) != 0) - builder.Append($" {tick} = N {NOTE_OFFSET_DRUMS_ACCENT + chartNumber} 0\n"); - if (canDynamics && (flags & Flags.ProDrums_Ghost) != 0) - builder.Append($" {tick} = N {NOTE_OFFSET_DRUMS_GHOST + chartNumber} 0\n"); - } - builder.Append("}\n"); + + // Not technically necessary, but might as well lol + int rawNote = gameMode switch { + GameMode.Guitar => (int)note.guitarFret, + GameMode.GHLGuitar => (int)note.ghliveGuitarFret, + GameMode.ProGuitar => throw new NotSupportedException(".chart does not support Pro Guitar!"), + GameMode.Drums => (int)note.drumPad, + _ => note.rawNote + }; + + int chartNumber = noteLookup[rawNote]; + if (canDoubleKick && (flags & Flags.DoubleKick) != 0) + chartNumber = NOTE_OFFSET_INSTRUMENT_PLUS; + + builder.Append($" {tick} = N {chartNumber} {note.length}\n"); + if (canForce && (flags & Flags.Forced) != 0) + builder.Append($" {tick} = N 5 0\n"); + if (canTap && (flags & Flags.Tap) != 0) + builder.Append($" {tick} = N 6 0\n"); + if (canCymbal && (flags & Flags.ProDrums_Cymbal) != 0) + builder.Append($" {tick} = N {NOTE_OFFSET_PRO_DRUMS + chartNumber} 0\n"); + if (canDynamics && (flags & Flags.ProDrums_Accent) != 0) + builder.Append($" {tick} = N {NOTE_OFFSET_DRUMS_ACCENT + chartNumber} 0\n"); + if (canDynamics && (flags & Flags.ProDrums_Ghost) != 0) + builder.Append($" {tick} = N {NOTE_OFFSET_DRUMS_GHOST + chartNumber} 0\n"); } - private static string GenerateChartFile() + private static string GenerateChartFile(MoonSong sourceSong) { - string header = $$""" - {{SECTION_SONG}} - { - Resolution = {{RESOLUTION}} - } - {{SECTION_SYNC_TRACK}} + var chartBuilder = new StringBuilder(5000); + GenerateSongSection(sourceSong, chartBuilder); + GenerateSyncSection(sourceSong, chartBuilder); + GenerateEventsSection(sourceSong, chartBuilder); + foreach (var instrument in EnumX.Values) + { + foreach (var difficulty in EnumX.Values) { - {{RESOLUTION * 0}} = TS {{NUMERATOR}} {{DENOMINATOR_POW2}} - {{RESOLUTION * 0}} = B {{(int)(TEMPO * 1000)}} + GenerateInstrumentSection(sourceSong, chartBuilder, instrument, difficulty); } - - """; // Trailing newline is deliberate - - var chartBuilder = new StringBuilder(header, 1000); - foreach (var difficulty in EnumX.Values) - { - GenerateSection(chartBuilder, GuitarNotes, MoonInstrument.Guitar, difficulty); - GenerateSection(chartBuilder, GhlGuitarNotes, MoonInstrument.GHLiveGuitar, difficulty); - GenerateSection(chartBuilder, DrumsNotes, MoonInstrument.Drums, difficulty); } return chartBuilder.ToString(); } @@ -134,11 +208,12 @@ private static string GenerateChartFile() [TestCase] public void GenerateAndParseChartFile() { - string chartText = GenerateChartFile(); - MoonSong song; + var sourceSong = GenerateSong(); + string chartText = GenerateChartFile(sourceSong); + MoonSong parsedSong; try { - song = ChartReader.ReadChart(new StringReader(chartText)); + parsedSong = ChartReader.ReadChart(new StringReader(chartText)); } catch (Exception ex) { @@ -146,17 +221,7 @@ public void GenerateAndParseChartFile() return; } - Assert.Multiple(() => - { - VerifyMetadata(song); - VerifySync(song); - foreach (var difficulty in EnumX.Values) - { - VerifyTrack(song, GuitarNotes, MoonInstrument.Guitar, difficulty); - VerifyTrack(song, GhlGuitarNotes, MoonInstrument.GHLiveGuitar, difficulty); - VerifyTrack(song, DrumsNotes, MoonInstrument.Drums, difficulty); - } - }); + VerifySong(sourceSong, parsedSong, InstrumentToNoteLookupLookup.Keys); } } } \ No newline at end of file diff --git a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Midi.cs b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Midi.cs index 7b3fc0307..20405a565 100644 --- a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Midi.cs +++ b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.Midi.cs @@ -1,4 +1,3 @@ -using System.Text; using Melanchall.DryWetMidi.Common; using Melanchall.DryWetMidi.Core; using MoonscraperChartEditor.Song; @@ -14,6 +13,8 @@ namespace YARG.Core.UnitTests.Parsing using static MidIOHelper; using static ParseBehaviorTests; + using MidiEventList = List<(long absoluteTick, MidiEvent midiEvent)>; + public class MidiParseBehaviorTests { private const uint SUSTAIN_CUTOFF_THRESHOLD = RESOLUTION / 3; @@ -38,6 +39,7 @@ public class MidiParseBehaviorTests { MoonInstrument.ProBass_22Fret, PRO_BASS_22_FRET_TRACK }, }; +#pragma warning disable IDE0230 // Use UTF-8 string literal private static readonly Dictionary GuitarNoteOffsetLookup = new() { { (int)GuitarFret.Open, -1 }, @@ -54,6 +56,15 @@ public class MidiParseBehaviorTests { MoonNoteType.Strum, 6 }, }; + private static readonly Dictionary GuitarSpecialPhraseLookup = new() + { + { SpecialPhrase.Type.Starpower, new[] { STARPOWER_NOTE } }, + { SpecialPhrase.Type.Versus_Player1, new[] { VERSUS_PHRASE_PLAYER_1 } }, + { SpecialPhrase.Type.Versus_Player2, new[] { VERSUS_PHRASE_PLAYER_2 } }, + { SpecialPhrase.Type.TremoloLane, new[] { TREMOLO_LANE_NOTE } }, + { SpecialPhrase.Type.TrillLane, new[] { TRILL_LANE_NOTE } }, + }; + private static readonly Dictionary GhlGuitarNoteOffsetLookup = new() { { (int)GHLiveGuitarFret.Open, 0 }, @@ -71,6 +82,11 @@ public class MidiParseBehaviorTests { MoonNoteType.Strum, 8 }, }; + private static readonly Dictionary GhlGuitarSpecialPhraseLookup = new() + { + { SpecialPhrase.Type.Starpower, new[] { STARPOWER_NOTE } }, + }; + private static readonly Dictionary ProGuitarNoteOffsetLookup = new() { { (int)ProGuitarString.Red, 0 }, @@ -89,6 +105,13 @@ public class MidiParseBehaviorTests private static readonly Dictionary ProGuitarChannelFlagLookup = PRO_GUITAR_CHANNEL_FLAG_LOOKUP.ToDictionary((pair) => pair.Value, (pair) => pair.Key); + private static readonly Dictionary ProGuitarSpecialPhraseLookup = new() + { + { SpecialPhrase.Type.Starpower, new[] { STARPOWER_NOTE } }, + { SpecialPhrase.Type.TremoloLane, new[] { TREMOLO_LANE_NOTE } }, + { SpecialPhrase.Type.TrillLane, new[] { TRILL_LANE_NOTE } }, + }; + private static readonly Dictionary DrumsNoteOffsetLookup = new() { { (int)DrumPad.Kick, 0 }, @@ -99,6 +122,16 @@ public class MidiParseBehaviorTests { (int)DrumPad.Green, 5 }, }; + private static readonly Dictionary DrumsSpecialPhraseLookup = new() + { + { SpecialPhrase.Type.Starpower, new[] { STARPOWER_NOTE } }, + { SpecialPhrase.Type.Versus_Player1, new[] { VERSUS_PHRASE_PLAYER_1 } }, + { SpecialPhrase.Type.Versus_Player2, new[] { VERSUS_PHRASE_PLAYER_2 } }, + { SpecialPhrase.Type.TremoloLane, new[] { TREMOLO_LANE_NOTE } }, + { SpecialPhrase.Type.TrillLane, new[] { TRILL_LANE_NOTE } }, + { SpecialPhrase.Type.ProDrums_Activation, new[] { DRUM_FILL_NOTE_0, DRUM_FILL_NOTE_1, DRUM_FILL_NOTE_2, DRUM_FILL_NOTE_3, DRUM_FILL_NOTE_4 } }, + }; + private static readonly Dictionary> InstrumentNoteOffsetLookup = new() { { GameMode.Guitar, GuitarNoteOffsetLookup }, @@ -131,56 +164,110 @@ public class MidiParseBehaviorTests { GameMode.ProGuitar, PRO_GUITAR_DIFF_START_LOOKUP }, }; + private static readonly Dictionary> InstrumentSpecialPhraseLookup = new() + { + { GameMode.Guitar, GuitarSpecialPhraseLookup }, + { GameMode.Drums, DrumsSpecialPhraseLookup }, + { GameMode.GHLGuitar, GhlGuitarSpecialPhraseLookup }, + { GameMode.ProGuitar, ProGuitarSpecialPhraseLookup }, + }; +#pragma warning restore IDE0230 + // Because SevenBitNumber andFourBitNumber have no implicit operators for taking in bytes private static SevenBitNumber S(byte number) => (SevenBitNumber)number; private static FourBitNumber F(byte number) => (FourBitNumber)number; - private static TrackChunk GenerateTrackChunk(List data, MoonInstrument instrument) + private static TrackChunk GenerateSyncChunk(MoonSong sourceSong) + { + var timedEvents = new MidiEventList(); + foreach (var sync in sourceSong.syncTrack) + { + switch (sync) + { + case BPM bpm: + // MIDI stores tempo as microseconds per quarter note, so we need to convert + // Moonscraper already ties BPM to quarter notes, so no additional conversion is needed + double secondsPerBeat = 60 / bpm.displayValue; + double microseconds = secondsPerBeat * 1000 * 1000; + timedEvents.Add((sync.tick, new SetTempoEvent((long)microseconds))); + break; + case TimeSignature ts: + timedEvents.Add((sync.tick, new TimeSignatureEvent((byte)ts.numerator, (byte)ts.denominator))); + break; + } + } + + return FinalizeTrackChunk("TEMPO_TRACK", timedEvents); + } + + private static TrackChunk GenerateEventsChunk(MoonSong sourceSong) + { + var timedEvents = new MidiEventList(); + foreach (var text in sourceSong.eventsAndSections) + { + timedEvents.Add((text.tick, new TextEvent(text.title))); + } + + return FinalizeTrackChunk(EVENTS_TRACK, timedEvents); + } + + private static TrackChunk GenerateTrackChunk(MoonSong sourceSong, MoonInstrument instrument) { - string instrumentName = InstrumentToNameLookup[instrument]; var gameMode = MoonSong.InstumentToChartGameMode(instrument); - var chunk = new TrackChunk(new SequenceTrackNameEvent(instrumentName)); + var timedEvents = new MidiEventList(); + // Text event flags to enable extended features if (gameMode == GameMode.Drums) - chunk.Events.Add(new TextEvent(CHART_DYNAMICS_TEXT_BRACKET)); + timedEvents.Add((0, new TextEvent(CHART_DYNAMICS_TEXT_BRACKET))); else if (gameMode == GameMode.Guitar) - chunk.Events.Add(new TextEvent(ENHANCED_OPENS_TEXT_BRACKET)); + timedEvents.Add((0, new TextEvent(ENHANCED_OPENS_TEXT_BRACKET))); - long deltaTime = 0; - long lastNoteStartDelta = 0; - foreach (var note in data) + long lastNoteTick = 0; + foreach (var difficulty in EnumX.Values) { - // Apply sustain cutoffs - if (note.length < (SUSTAIN_CUTOFF_THRESHOLD)) - note.length = 0; - - // Note ons - // *MUST* be generated on all difficulties before note offs! Otherwise notes will be placed incorrectly - long currentDelta = deltaTime; - foreach (var difficulty in EnumX.Values) + var chart = sourceSong.GetChart(instrument, difficulty); + foreach (var chartObj in chart.chartObjects) { - GenerateNotesForDifficulty(chunk, gameMode, difficulty, note, currentDelta, VELOCITY, lastNoteStartDelta); - currentDelta = 0; + switch (chartObj) + { + case MoonNote note: + GenerateNote(timedEvents, note, gameMode, difficulty, ref lastNoteTick); + break; + case SpecialPhrase phrase when difficulty == Difficulty.Expert: + GenerateSpecialPhrase(timedEvents, phrase, gameMode); + break; + case ChartEvent text when difficulty == Difficulty.Expert: + timedEvents.Add((text.tick, new TextEvent(text.eventName))); + break; + } } - - // Note offs - long endDelta = Math.Max(note.length, 1); - currentDelta = endDelta; - foreach (var difficulty in EnumX.Values) - { - GenerateNotesForDifficulty(chunk, gameMode, difficulty, note, currentDelta, 0, lastNoteStartDelta); - currentDelta = 0; - } - - deltaTime = RESOLUTION - endDelta; - lastNoteStartDelta = RESOLUTION; } - return chunk; + // Write events to new track + string instrumentName = InstrumentToNameLookup[instrument]; + return FinalizeTrackChunk(instrumentName, timedEvents); + } + + private static void GenerateNote(MidiEventList events, MoonNote note, GameMode gameMode, Difficulty difficulty, + ref long lastNoteTick) + { + // Apply sustain cutoffs + if (note.length < (SUSTAIN_CUTOFF_THRESHOLD)) + note.length = 0; + + // Write notes + long startTick = note.tick; + long endTick = startTick + Math.Max(note.length, 1); + long lastNoteDelta = startTick - lastNoteTick; + GenerateNotesForDifficulty(events, gameMode, difficulty, note, startTick, VELOCITY, lastNoteDelta); + GenerateNotesForDifficulty(events, gameMode, difficulty, note, endTick, 0, lastNoteDelta); + + // Keep track of last note tick for HOPO marking + lastNoteTick = startTick; } - private static void GenerateNotesForDifficulty(TrackChunk chunk, GameMode gameMode, Difficulty difficulty, - MoonNote note, long noteDelta, byte velocity, long lastStartDelta) + private static void GenerateNotesForDifficulty(MidiEventList events, GameMode gameMode, Difficulty difficulty, + MoonNote note, long noteTick, byte velocity, long lastStartDelta) where TNoteEvent : NoteEvent, new() { // This code is somewhat hacky and makes a lot of assumptions, but it does the job @@ -234,57 +321,118 @@ private static void GenerateNotesForDifficulty(TrackChunk chunk, Gam channel = 0; // Main note - chunk.Events.Add(new TNoteEvent() { NoteNumber = S(noteNumber), Velocity = S(velocity), DeltaTime = noteDelta, Channel = F(channel) }); + var midiNote = new TNoteEvent() + { + NoteNumber = S(noteNumber), + Velocity = S(velocity), + DeltaTime = noteTick, + Channel = F(channel) + }; + events.Add((noteTick, midiNote)); // Note flags if ((canForceStrum || canForceHopo) && (flags & Flags.Forced) != 0) { - byte forceNote; + MoonNoteType type; if (canForceHopo && lastStartDelta is >= HOPO_THRESHOLD and > 0) - forceNote = (byte)(difficultyStart + forceOffsetLookup[MoonNoteType.Hopo]); + type = MoonNoteType.Hopo; else - forceNote = (byte)(difficultyStart + forceOffsetLookup[MoonNoteType.Strum]); - chunk.Events.Add(new TNoteEvent() { NoteNumber = S(forceNote), Velocity = S(velocity) }); + type = MoonNoteType.Strum; + + byte forceNote = (byte)(difficultyStart + forceOffsetLookup[type]); + midiNote = new TNoteEvent() { NoteNumber = S(forceNote), Velocity = S(velocity) }; + events.Add((noteTick, midiNote)); } if (canTap && (flags & Flags.Tap) != 0) - chunk.Events.Add(new TNoteEvent() { NoteNumber = S(TAP_NOTE_CH), Velocity = S(velocity) }); + { + midiNote = new TNoteEvent() { NoteNumber = S(TAP_NOTE_CH), Velocity = S(velocity) }; + events.Add((noteTick, midiNote)); + } if (canTom && PAD_TO_CYMBAL_LOOKUP.TryGetValue((DrumPad)rawNote, out int padNote) && (flags & Flags.ProDrums_Cymbal) == 0) - chunk.Events.Add(new TNoteEvent() { NoteNumber = S((byte)padNote), Velocity = S(velocity) }); + { + midiNote = new TNoteEvent() { NoteNumber = S((byte)padNote), Velocity = S(velocity) }; + events.Add((noteTick, midiNote)); + } + } + + private static void GenerateSpecialPhrase(MidiEventList events, SpecialPhrase phrase, GameMode gameMode) + { + // Apply sustain cutoffs + if (phrase.length < (SUSTAIN_CUTOFF_THRESHOLD)) + phrase.length = 0; + + // Write notes + long startTick = phrase.tick; + long endTick = startTick + Math.Max(phrase.length, 1); + byte[] notesToAdd = InstrumentSpecialPhraseLookup[gameMode][phrase.type]; + foreach (byte note in notesToAdd) + { + events.Add((startTick, new NoteOnEvent() { NoteNumber = S(note), Velocity = S(VELOCITY) })); + events.Add((endTick, new NoteOffEvent() { NoteNumber = S(note), Velocity = S(0) })); + } } - private static MidiFile GenerateMidi() + private static TrackChunk FinalizeTrackChunk(string trackName, MidiEventList events) + { + // Sort events by time + events.Sort((ev1, ev2) => { + if (ev1.absoluteTick > ev2.absoluteTick) + return 1; + else if (ev1.absoluteTick < ev2.absoluteTick) + return -1; + + return 0; + }); + + // Calculate delta time + long previousTick = 0; + foreach (var (tick, midi) in events) + { + long delta = tick - previousTick; + midi.DeltaTime = delta; + previousTick = tick; + } + + // Write events to new track + // Track name is written here to ensure it is the first event + var chunk = new TrackChunk(new SequenceTrackNameEvent(trackName)); + chunk.Events.AddRange(events.Select((ev) => ev.midiEvent)); + return chunk; + } + + private static MidiFile GenerateMidi(MoonSong sourceSong) { - byte denominator = 1; - for (int i = 0; i < DENOMINATOR_POW2; i++) - denominator *= 2; - - var sync = new TrackChunk( - new SequenceTrackNameEvent("TEMPO_TRACK"), - new SetTempoEvent((long)((60 / TEMPO) * 1000000)), - new TimeSignatureEvent(NUMERATOR, denominator) - ); var midi = new MidiFile( - sync, - GenerateTrackChunk(GuitarNotes, MoonInstrument.Guitar), - GenerateTrackChunk(GhlGuitarNotes, MoonInstrument.GHLiveGuitar), - GenerateTrackChunk(ProGuitarNotes, MoonInstrument.ProGuitar_22Fret), - GenerateTrackChunk(DrumsNotes, MoonInstrument.Drums)) + GenerateSyncChunk(sourceSong), + GenerateEventsChunk(sourceSong) + ) { - TimeDivision = new TicksPerQuarterNoteTimeDivision((short)RESOLUTION) + TimeDivision = new TicksPerQuarterNoteTimeDivision((short)sourceSong.resolution) }; + foreach (var instrument in EnumX.Values) + { + var gameMode = MoonSong.InstumentToChartGameMode(instrument); + if (!InstrumentNoteOffsetLookup.ContainsKey(gameMode)) + continue; + + var chunk = GenerateTrackChunk(sourceSong, instrument); + midi.Chunks.Add(chunk); + } + return midi; } [TestCase] public void GenerateAndParseMidiFile() { - var midi = GenerateMidi(); - MoonSong song; + var sourceSong = GenerateSong(); + var midi = GenerateMidi(sourceSong); + MoonSong parsedSong; try { - song = MidReader.ReadMidi(midi); + parsedSong = MidReader.ReadMidi(midi); } catch (Exception ex) { @@ -292,18 +440,7 @@ public void GenerateAndParseMidiFile() return; } - Assert.Multiple(() => - { - VerifyMetadata(song); - VerifySync(song); - foreach (var difficulty in EnumX.Values) - { - VerifyTrack(song, GuitarNotes, MoonInstrument.Guitar, difficulty); - VerifyTrack(song, GhlGuitarNotes, MoonInstrument.GHLiveGuitar, difficulty); - VerifyTrack(song, ProGuitarNotes, MoonInstrument.ProGuitar_22Fret, difficulty); - VerifyTrack(song, DrumsNotes, MoonInstrument.Drums, difficulty); - } - }); + VerifySong(sourceSong, parsedSong, InstrumentNoteOffsetLookup.Keys); } } } \ No newline at end of file diff --git a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.cs b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.cs index c9dc7f1df..2e3a959d4 100644 --- a/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.cs +++ b/YARG.Core.UnitTests/Parsing/ParseBehaviorTests.cs @@ -1,9 +1,11 @@ using MoonscraperChartEditor.Song; +using MoonscraperEngine; using NUnit.Framework; namespace YARG.Core.UnitTests.Parsing { using static MoonSong; + using static MoonChart; using static MoonNote; public class ParseBehaviorTests @@ -11,182 +13,374 @@ public class ParseBehaviorTests public const uint RESOLUTION = 192; public const double TEMPO = 120.0; public const int NUMERATOR = 4; - public const int DENOMINATOR_POW2 = 2; + public const int DENOMINATOR = 4; public const uint HOPO_THRESHOLD = (uint)(SongConfig.FORCED_NOTE_TICK_THRESHOLD * RESOLUTION / SongConfig.STANDARD_BEAT_RESOLUTION); - private static MoonNote NewNote(GuitarFret fret, uint length = 0, Flags flags = Flags.None) - => new(0, (int)fret, length, flags); - private static MoonNote NewNote(GHLiveGuitarFret fret, uint length = 0, Flags flags = Flags.None) - => new(0, (int)fret, length, flags); - private static MoonNote NewNote(DrumPad pad, uint length = 0, Flags flags = Flags.None) - => new(0, (int)pad, length, flags); - private static MoonNote NewNote(ProGuitarString str, int fret, uint length = 0, Flags flags = Flags.None) - => new(0, MoonNote.MakeProGuitarRawNote(str, fret), length, flags); - - public static readonly List GuitarNotes = new() - { - NewNote(GuitarFret.Green), - NewNote(GuitarFret.Red), - NewNote(GuitarFret.Yellow), - NewNote(GuitarFret.Blue), - NewNote(GuitarFret.Orange), - NewNote(GuitarFret.Open), - - NewNote(GuitarFret.Green, flags: Flags.Forced), - NewNote(GuitarFret.Red, flags: Flags.Forced), - NewNote(GuitarFret.Yellow, flags: Flags.Forced), - NewNote(GuitarFret.Blue, flags: Flags.Forced), - NewNote(GuitarFret.Orange, flags: Flags.Forced), - NewNote(GuitarFret.Open, flags: Flags.Forced), - - NewNote(GuitarFret.Green, flags: Flags.Tap), - NewNote(GuitarFret.Red, flags: Flags.Tap), - NewNote(GuitarFret.Yellow, flags: Flags.Tap), - NewNote(GuitarFret.Blue, flags: Flags.Tap), - NewNote(GuitarFret.Orange, flags: Flags.Tap), + public static readonly SongObjectComparer Comparer = new(); + + public static readonly List TempoMap = new() + { + new BPM(0, (uint)(TEMPO * 1000)), + new TimeSignature(0, NUMERATOR, DENOMINATOR), }; - public static readonly List GhlGuitarNotes = new() - { - NewNote(GHLiveGuitarFret.Black1), - NewNote(GHLiveGuitarFret.Black2), - NewNote(GHLiveGuitarFret.Black3), - NewNote(GHLiveGuitarFret.White1), - NewNote(GHLiveGuitarFret.White2), - NewNote(GHLiveGuitarFret.White3), - NewNote(GHLiveGuitarFret.Open), - - NewNote(GHLiveGuitarFret.Black1, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.Black2, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.Black3, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.White1, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.White2, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.White3, flags: Flags.Forced), - NewNote(GHLiveGuitarFret.Open, flags: Flags.Forced), - - NewNote(GHLiveGuitarFret.Black1, flags: Flags.Tap), - NewNote(GHLiveGuitarFret.Black2, flags: Flags.Tap), - NewNote(GHLiveGuitarFret.Black3, flags: Flags.Tap), - NewNote(GHLiveGuitarFret.White1, flags: Flags.Tap), - NewNote(GHLiveGuitarFret.White2, flags: Flags.Tap), - NewNote(GHLiveGuitarFret.White3, flags: Flags.Tap), + public static readonly List GlobalEvents = new() + { }; - public static readonly List ProGuitarNotes = new() - { - NewNote(ProGuitarString.Red, 0), - NewNote(ProGuitarString.Green, 1), - NewNote(ProGuitarString.Orange, 2), - NewNote(ProGuitarString.Blue, 3), - NewNote(ProGuitarString.Yellow, 4), - NewNote(ProGuitarString.Purple, 5), - - NewNote(ProGuitarString.Red, 6, flags: Flags.Forced), - NewNote(ProGuitarString.Green, 7, flags: Flags.Forced), - NewNote(ProGuitarString.Orange, 8, flags: Flags.Forced), - NewNote(ProGuitarString.Blue, 9, flags: Flags.Forced), - NewNote(ProGuitarString.Yellow, 10, flags: Flags.Forced), - NewNote(ProGuitarString.Purple, 11, flags: Flags.Forced), - - NewNote(ProGuitarString.Red, 12, flags: Flags.ProGuitar_Muted), - NewNote(ProGuitarString.Green, 13, flags: Flags.ProGuitar_Muted), - NewNote(ProGuitarString.Orange, 14, flags: Flags.ProGuitar_Muted), - NewNote(ProGuitarString.Blue, 15, flags: Flags.ProGuitar_Muted), - NewNote(ProGuitarString.Yellow, 16, flags: Flags.ProGuitar_Muted), - NewNote(ProGuitarString.Purple, 17, flags: Flags.ProGuitar_Muted), + private static MoonNote NewNote(int index, GuitarFret fret, uint length = 0, Flags flags = Flags.None) + => new((uint)(index * RESOLUTION), (int)fret, length, flags); + private static MoonNote NewNote(int index, GHLiveGuitarFret fret, uint length = 0, Flags flags = Flags.None) + => new((uint)(index * RESOLUTION), (int)fret, length, flags); + private static MoonNote NewNote(int index, DrumPad pad, uint length = 0, Flags flags = Flags.None) + => new((uint)(index * RESOLUTION), (int)pad, length, flags); + private static MoonNote NewNote(int index, ProGuitarString str, int fret, uint length = 0, Flags flags = Flags.None) + => new((uint)(index * RESOLUTION), MoonNote.MakeProGuitarRawNote(str, fret), length, flags); + private static SpecialPhrase NewSpecial(int index, SpecialPhrase.Type type, uint length = 0) + => new((uint)(index * RESOLUTION), length, type); + + public static readonly List GuitarTrack = new() + { + NewNote(0, GuitarFret.Green), + NewNote(1, GuitarFret.Red), + NewNote(2, GuitarFret.Yellow), + NewNote(3, GuitarFret.Blue), + NewNote(4, GuitarFret.Orange), + NewNote(5, GuitarFret.Open), + + NewSpecial(6, SpecialPhrase.Type.Versus_Player1, RESOLUTION * 6), + NewNote(6, GuitarFret.Green, flags: Flags.Forced), + NewNote(7, GuitarFret.Red, flags: Flags.Forced), + NewNote(8, GuitarFret.Yellow, flags: Flags.Forced), + NewNote(9, GuitarFret.Blue, flags: Flags.Forced), + NewNote(10, GuitarFret.Orange, flags: Flags.Forced), + NewNote(11, GuitarFret.Open, flags: Flags.Forced), + + NewSpecial(12, SpecialPhrase.Type.Versus_Player2, RESOLUTION * 5), + NewNote(12, GuitarFret.Green, flags: Flags.Tap), + NewNote(13, GuitarFret.Red, flags: Flags.Tap), + NewNote(14, GuitarFret.Yellow, flags: Flags.Tap), + NewNote(15, GuitarFret.Blue, flags: Flags.Tap), + NewNote(16, GuitarFret.Orange, flags: Flags.Tap), + + NewSpecial(17, SpecialPhrase.Type.Starpower, RESOLUTION * 6), + NewNote(17, GuitarFret.Green), + NewNote(18, GuitarFret.Red), + NewNote(19, GuitarFret.Yellow), + NewNote(20, GuitarFret.Blue), + NewNote(21, GuitarFret.Orange), + NewNote(22, GuitarFret.Open), + + NewSpecial(23, SpecialPhrase.Type.TremoloLane, RESOLUTION * 6), + NewNote(23, GuitarFret.Yellow), + NewNote(24, GuitarFret.Yellow), + NewNote(25, GuitarFret.Yellow), + NewNote(26, GuitarFret.Yellow), + NewNote(27, GuitarFret.Yellow), + NewNote(28, GuitarFret.Yellow), + + NewSpecial(29, SpecialPhrase.Type.TrillLane, RESOLUTION * 6), + NewNote(29, GuitarFret.Green), + NewNote(30, GuitarFret.Red), + NewNote(31, GuitarFret.Green), + NewNote(32, GuitarFret.Red), + NewNote(33, GuitarFret.Green), + NewNote(34, GuitarFret.Red), }; - public static readonly List DrumsNotes = new() - { - NewNote(DrumPad.Kick), - NewNote(DrumPad.Kick, flags: Flags.DoubleKick), - - NewNote(DrumPad.Red, length: 16), - NewNote(DrumPad.Yellow, length: 16), - NewNote(DrumPad.Blue, length: 16), - NewNote(DrumPad.Orange, length: 16), - NewNote(DrumPad.Green, length: 16), - NewNote(DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), - NewNote(DrumPad.Blue, flags: Flags.ProDrums_Cymbal), - NewNote(DrumPad.Orange, flags: Flags.ProDrums_Cymbal), - - NewNote(DrumPad.Red, flags: Flags.ProDrums_Accent), - NewNote(DrumPad.Yellow, flags: Flags.ProDrums_Accent), - NewNote(DrumPad.Blue, flags: Flags.ProDrums_Accent), - NewNote(DrumPad.Orange, flags: Flags.ProDrums_Accent), - NewNote(DrumPad.Green, flags: Flags.ProDrums_Accent), - NewNote(DrumPad.Yellow, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), - NewNote(DrumPad.Blue, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), - NewNote(DrumPad.Orange, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), - - NewNote(DrumPad.Red, flags: Flags.ProDrums_Ghost), - NewNote(DrumPad.Yellow, flags: Flags.ProDrums_Ghost), - NewNote(DrumPad.Blue, flags: Flags.ProDrums_Ghost), - NewNote(DrumPad.Orange, flags: Flags.ProDrums_Ghost), - NewNote(DrumPad.Green, flags: Flags.ProDrums_Ghost), - NewNote(DrumPad.Yellow, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), - NewNote(DrumPad.Blue, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), - NewNote(DrumPad.Orange, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), + public static readonly List GhlGuitarTrack = new() + { + NewNote(0, GHLiveGuitarFret.Black1), + NewNote(1, GHLiveGuitarFret.Black2), + NewNote(2, GHLiveGuitarFret.Black3), + NewNote(3, GHLiveGuitarFret.White1), + NewNote(4, GHLiveGuitarFret.White2), + NewNote(5, GHLiveGuitarFret.White3), + NewNote(6, GHLiveGuitarFret.Open), + + NewNote(7, GHLiveGuitarFret.Black1, flags: Flags.Forced), + NewNote(8, GHLiveGuitarFret.Black2, flags: Flags.Forced), + NewNote(9, GHLiveGuitarFret.Black3, flags: Flags.Forced), + NewNote(10, GHLiveGuitarFret.White1, flags: Flags.Forced), + NewNote(11, GHLiveGuitarFret.White2, flags: Flags.Forced), + NewNote(12, GHLiveGuitarFret.White3, flags: Flags.Forced), + NewNote(13, GHLiveGuitarFret.Open, flags: Flags.Forced), + + NewNote(14, GHLiveGuitarFret.Black1, flags: Flags.Tap), + NewNote(15, GHLiveGuitarFret.Black2, flags: Flags.Tap), + NewNote(16, GHLiveGuitarFret.Black3, flags: Flags.Tap), + NewNote(17, GHLiveGuitarFret.White1, flags: Flags.Tap), + NewNote(18, GHLiveGuitarFret.White2, flags: Flags.Tap), + NewNote(19, GHLiveGuitarFret.White3, flags: Flags.Tap), + + NewSpecial(20, SpecialPhrase.Type.Starpower, RESOLUTION * 7), + NewNote(20, GHLiveGuitarFret.Black1), + NewNote(21, GHLiveGuitarFret.Black2), + NewNote(22, GHLiveGuitarFret.Black3), + NewNote(23, GHLiveGuitarFret.White1), + NewNote(24, GHLiveGuitarFret.White2), + NewNote(25, GHLiveGuitarFret.White3), + NewNote(26, GHLiveGuitarFret.Open), }; - public static void VerifyMetadata(MoonSong song) + public static readonly List ProGuitarTrack = new() { - Assert.Multiple(() => + NewNote(0, ProGuitarString.Red, 0), + NewNote(1, ProGuitarString.Green, 1), + NewNote(2, ProGuitarString.Orange, 2), + NewNote(3, ProGuitarString.Blue, 3), + NewNote(4, ProGuitarString.Yellow, 4), + NewNote(5, ProGuitarString.Purple, 5), + + NewNote(6, ProGuitarString.Red, 6, flags: Flags.Forced), + NewNote(7, ProGuitarString.Green, 7, flags: Flags.Forced), + NewNote(8, ProGuitarString.Orange, 8, flags: Flags.Forced), + NewNote(9, ProGuitarString.Blue, 9, flags: Flags.Forced), + NewNote(10, ProGuitarString.Yellow, 10, flags: Flags.Forced), + NewNote(11, ProGuitarString.Purple, 11, flags: Flags.Forced), + + NewNote(12, ProGuitarString.Red, 12, flags: Flags.ProGuitar_Muted), + NewNote(13, ProGuitarString.Green, 13, flags: Flags.ProGuitar_Muted), + NewNote(14, ProGuitarString.Orange, 14, flags: Flags.ProGuitar_Muted), + NewNote(15, ProGuitarString.Blue, 15, flags: Flags.ProGuitar_Muted), + NewNote(16, ProGuitarString.Yellow, 16, flags: Flags.ProGuitar_Muted), + NewNote(17, ProGuitarString.Purple, 17, flags: Flags.ProGuitar_Muted), + + NewSpecial(18, SpecialPhrase.Type.Starpower, RESOLUTION * 6), + NewNote(18, ProGuitarString.Red, 0), + NewNote(19, ProGuitarString.Green, 1), + NewNote(20, ProGuitarString.Orange, 2), + NewNote(21, ProGuitarString.Blue, 3), + NewNote(22, ProGuitarString.Yellow, 4), + NewNote(23, ProGuitarString.Purple, 5), + + NewSpecial(24, SpecialPhrase.Type.TremoloLane, RESOLUTION * 6), + NewNote(24, ProGuitarString.Red, 0), + NewNote(25, ProGuitarString.Red, 0), + NewNote(26, ProGuitarString.Red, 0), + NewNote(27, ProGuitarString.Red, 0), + NewNote(28, ProGuitarString.Red, 0), + NewNote(29, ProGuitarString.Red, 0), + + NewSpecial(30, SpecialPhrase.Type.TrillLane, RESOLUTION * 6), + NewNote(30, ProGuitarString.Yellow, 5), + NewNote(31, ProGuitarString.Yellow, 6), + NewNote(32, ProGuitarString.Yellow, 5), + NewNote(33, ProGuitarString.Yellow, 6), + NewNote(34, ProGuitarString.Yellow, 5), + NewNote(35, ProGuitarString.Yellow, 6), + }; + + public static readonly List DrumsTrack = new() + { + NewNote(0, DrumPad.Kick), + NewNote(1, DrumPad.Kick, flags: Flags.DoubleKick), + + NewNote(2, DrumPad.Red, length: 16), + NewNote(3, DrumPad.Yellow, length: 16), + NewNote(4, DrumPad.Blue, length: 16), + NewNote(5, DrumPad.Orange, length: 16), + NewNote(6, DrumPad.Green, length: 16), + NewNote(7, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(8, DrumPad.Blue, flags: Flags.ProDrums_Cymbal), + NewNote(9, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + + NewNote(10, DrumPad.Red, flags: Flags.ProDrums_Accent), + NewNote(11, DrumPad.Yellow, flags: Flags.ProDrums_Accent), + NewNote(12, DrumPad.Blue, flags: Flags.ProDrums_Accent), + NewNote(13, DrumPad.Orange, flags: Flags.ProDrums_Accent), + NewNote(14, DrumPad.Green, flags: Flags.ProDrums_Accent), + NewNote(15, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), + NewNote(16, DrumPad.Blue, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), + NewNote(17, DrumPad.Orange, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Accent), + + NewNote(18, DrumPad.Red, flags: Flags.ProDrums_Ghost), + NewNote(19, DrumPad.Yellow, flags: Flags.ProDrums_Ghost), + NewNote(20, DrumPad.Blue, flags: Flags.ProDrums_Ghost), + NewNote(21, DrumPad.Orange, flags: Flags.ProDrums_Ghost), + NewNote(22, DrumPad.Green, flags: Flags.ProDrums_Ghost), + NewNote(23, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), + NewNote(24, DrumPad.Blue, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), + NewNote(25, DrumPad.Orange, flags: Flags.ProDrums_Cymbal | Flags.ProDrums_Ghost), + + NewSpecial(26, SpecialPhrase.Type.Starpower, RESOLUTION * 8), + NewNote(26, DrumPad.Red), + NewNote(27, DrumPad.Yellow), + NewNote(28, DrumPad.Blue), + NewNote(29, DrumPad.Orange), + NewNote(30, DrumPad.Green), + NewNote(31, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(32, DrumPad.Blue, flags: Flags.ProDrums_Cymbal), + NewNote(33, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + + NewSpecial(34, SpecialPhrase.Type.ProDrums_Activation, RESOLUTION * 5), + NewNote(34, DrumPad.Red), + NewNote(35, DrumPad.Yellow), + NewNote(36, DrumPad.Blue), + NewNote(37, DrumPad.Orange), + NewNote(38, DrumPad.Green), + NewNote(39, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(40, DrumPad.Blue, flags: Flags.ProDrums_Cymbal), + NewNote(41, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + + NewSpecial(42, SpecialPhrase.Type.Versus_Player1, RESOLUTION * 8), + NewNote(42, DrumPad.Red), + NewNote(43, DrumPad.Yellow), + NewNote(44, DrumPad.Blue), + NewNote(45, DrumPad.Orange), + NewNote(46, DrumPad.Green), + NewNote(47, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(48, DrumPad.Blue, flags: Flags.ProDrums_Cymbal), + NewNote(49, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + + NewSpecial(50, SpecialPhrase.Type.Versus_Player2, RESOLUTION * 8), + NewNote(50, DrumPad.Red), + NewNote(51, DrumPad.Yellow), + NewNote(52, DrumPad.Blue), + NewNote(53, DrumPad.Orange), + NewNote(54, DrumPad.Green), + NewNote(55, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(56, DrumPad.Blue, flags: Flags.ProDrums_Cymbal), + NewNote(57, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + + NewSpecial(58, SpecialPhrase.Type.TremoloLane, RESOLUTION * 6), + NewNote(58, DrumPad.Red), + NewNote(59, DrumPad.Red), + NewNote(60, DrumPad.Red), + NewNote(61, DrumPad.Red), + NewNote(62, DrumPad.Red), + NewNote(63, DrumPad.Red), + + NewSpecial(64, SpecialPhrase.Type.TrillLane, RESOLUTION * 6), + NewNote(64, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(65, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + NewNote(66, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(67, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + NewNote(68, DrumPad.Yellow, flags: Flags.ProDrums_Cymbal), + NewNote(69, DrumPad.Orange, flags: Flags.ProDrums_Cymbal), + }; + + public static MoonSong GenerateSong() + { + var song = new MoonSong(); + PopulateSyncTrack(song, TempoMap); + PopulateGlobalEvents(song, GlobalEvents); + foreach (var instrument in EnumX.Values) { - Assert.That(song.resolution, Is.EqualTo((float)RESOLUTION), $"Resolution was not parsed correctly!"); - }); + var gameMode = MoonSong.InstumentToChartGameMode(instrument); + var data = GameModeToChartData(gameMode); + PopulateInstrument(song, instrument, data); + } + return song; } - public static void VerifySync(MoonSong song) + public static List GameModeToChartData(GameMode gameMode) { - Assert.Multiple(() => + return gameMode switch { + GameMode.Guitar => GuitarTrack, + GameMode.GHLGuitar => GhlGuitarTrack, + GameMode.ProGuitar => ProGuitarTrack, + GameMode.Drums => DrumsTrack, + GameMode.Vocals => new(), // TODO + _ => throw new NotImplementedException($"No note data for game mode {gameMode}") + }; + } + + public static void PopulateSyncTrack(MoonSong song, List tempoMap) + { + foreach (var sync in tempoMap) + { + song.Add(sync.Clone(), false); + } + song.UpdateCache(); + } + + public static void PopulateGlobalEvents(MoonSong song, List events) + { + foreach (var text in events) { - Assert.That(song.bpms, Has.Count.EqualTo(1), $"Incorrect number of BPM events!"); - Assert.That(song.timeSignatures, Has.Count.EqualTo(1), $"Incorrect number of time signatures!"); + song.Add(text.Clone(), false); + } + song.UpdateCache(); + } - if (song.bpms.Count > 0) - Assert.That(song.bpms[0].displayValue, Is.InRange(TEMPO - 0.001, TEMPO + 0.001), "Parsed tempo is incorrect!"); + public static void PopulateInstrument(MoonSong song, MoonInstrument instrument, List data) + { + foreach (var difficulty in EnumX.Values) + { + PopulateDifficulty(song, instrument, difficulty, data); + } + } - if (song.timeSignatures.Count > 0) + public static void PopulateDifficulty(MoonSong song, MoonInstrument instrument, Difficulty difficulty, List data) + { + var chart = song.GetChart(instrument, difficulty); + foreach (var chartObj in data) + { + chart.Add(chartObj.Clone(), false); + } + chart.UpdateCache(); + } + + public static void VerifySong(MoonSong sourceSong, MoonSong parsedSong, IEnumerable supportedModes) + { + Assert.Multiple(() => + { + VerifyMetadata(sourceSong, parsedSong); + VerifySync(sourceSong, parsedSong); + foreach (var instrument in EnumX.Values) { - Assert.That(song.timeSignatures[0].numerator, Is.EqualTo(NUMERATOR), "Parsed numerator is incorrect!"); - uint denominator = song.timeSignatures[0].denominator; - uint denominator_pow2 = denominator; - for (int i = 1; i < DENOMINATOR_POW2; i++) - denominator_pow2 /= 2; - Assert.That(denominator_pow2, Is.EqualTo(DENOMINATOR_POW2), $"Parsed denominator is incorrect! (Original: {denominator})"); + // Skip unsupported instruments + var gameMode = MoonSong.InstumentToChartGameMode(instrument); + if (!supportedModes.Contains(gameMode)) + continue; + + VerifyInstrument(sourceSong, parsedSong, instrument); } }); } - public static void VerifyTrack(MoonSong song, List data, MoonInstrument instrument, Difficulty difficulty) + public static void VerifyMetadata(MoonSong sourceSong, MoonSong parsedSong) + { + Assert.Multiple(() => + { + Assert.That(parsedSong.resolution, Is.EqualTo(sourceSong.resolution), $"Resolution was not parsed correctly!"); + }); + } + + public static void VerifySync(MoonSong sourceSong, MoonSong parsedSong) { Assert.Multiple(() => { - bool chartExists = song.DoesChartExist(instrument, difficulty); + CollectionAssert.AreEqual(sourceSong.bpms, parsedSong.bpms, Comparer, "BPMs do not match!"); + CollectionAssert.AreEqual(sourceSong.timeSignatures, parsedSong.timeSignatures, Comparer, "Time signatures do not match!"); + CollectionAssert.AreEqual(parsedSong.events, parsedSong.events, Comparer, "Global events do not match!"); + }); + } + + public static void VerifyInstrument(MoonSong sourceSong, MoonSong parsedSong, MoonInstrument instrument) + { + foreach (var difficulty in EnumX.Values) + { + VerifyDifficulty(sourceSong, parsedSong, instrument, difficulty); + } + } + + public static void VerifyDifficulty(MoonSong sourceSong, MoonSong parsedSong, MoonInstrument instrument, Difficulty difficulty) + { + Assert.Multiple(() => + { + bool chartExists = parsedSong.DoesChartExist(instrument, difficulty); Assert.That(chartExists, Is.True, $"Chart for {difficulty} {instrument} was not parsed!"); if (!chartExists) return; - var chart = song.GetChart(instrument, difficulty); - for (int index = 0; index < data.Count; index++) - { - uint tick = RESOLUTION * (uint)index; - var originalNote = data[index]; - SongObjectHelper.FindObjectsAtPosition(tick, chart.notes, out int start, out int length); - Assert.That(start, Is.Not.EqualTo(SongObjectHelper.NOTFOUND), $"Note at position {tick} was not parsed on {difficulty} {instrument}!"); - Assert.That(length, Is.AtLeast(1), $"Note at position {tick} was not parsed on {difficulty} {instrument}!"); - Assert.That(length, Is.AtMost(1), $"More than one note was found at position {tick} on {difficulty} {instrument}!"); - if (start == SongObjectHelper.NOTFOUND || length != 1) - continue; - - var parsedNote = chart.notes[start]; - Assert.That(parsedNote.tick, Is.EqualTo(tick), $"Note position does not match! (Note {originalNote.rawNote} on {difficulty} {instrument})"); - Assert.That(parsedNote.rawNote, Is.EqualTo(originalNote.rawNote), $"Raw note does not match! (Tick {tick} on {difficulty} {instrument})"); - Assert.That(parsedNote.length, Is.EqualTo(originalNote.length), $"Note length does not match! (Note {originalNote.rawNote} at {tick} on {difficulty} {instrument})"); - Assert.That(parsedNote.flags, Is.EqualTo(originalNote.flags), $"Note flags do not match! (Note {originalNote.rawNote} at {tick} on {difficulty} {instrument})"); - } + var sourceChart = sourceSong.GetChart(instrument, difficulty); + var parsedChart = parsedSong.GetChart(instrument, difficulty); + CollectionAssert.AreEqual(sourceChart.notes, parsedChart.notes, Comparer, $"Notes on {difficulty} {instrument} do not match!"); + CollectionAssert.AreEqual(sourceChart.specialPhrases, parsedChart.specialPhrases, Comparer, $"Special phrases on {difficulty} {instrument} do not match!"); + CollectionAssert.AreEqual(sourceChart.events, parsedChart.events, Comparer, $"Local events on {difficulty} {instrument} do not match!"); }); } } diff --git a/YARG.Core.UnitTests/Parsing/SongObjectComparer.cs b/YARG.Core.UnitTests/Parsing/SongObjectComparer.cs new file mode 100644 index 000000000..6a5e38e2b --- /dev/null +++ b/YARG.Core.UnitTests/Parsing/SongObjectComparer.cs @@ -0,0 +1,53 @@ +using System.Collections; +using MoonscraperChartEditor.Song; + +namespace YARG.Core.UnitTests.Parsing +{ + public class SongObjectComparer : IComparer, IComparer + { + public int Compare(SongObject? x, SongObject? y) + { + // Some SongObject types need additional comparison logic that can't be added directly + // without potentially impacting object sorting in a negative way + switch ((x, y)) + { + case (BPM bx, BPM by): + if (bx == by) + { + if (bx.value > by.value) + return 1; + else if (bx.value < by.value) + return -1; + return 0; + } + goto default; + + case (TimeSignature tx, TimeSignature ty): + if (tx == ty) + { + if (tx.numerator > ty.numerator || tx.denominator > ty.denominator) + return 1; + else if (tx.numerator < ty.numerator || tx.denominator < ty.denominator) + return -1; + return 0; + } + goto default; + + default: + if (x == y) + return 0; + else if (x > y) + return 1; + return -1; + } + } + + public int Compare(object? x, object? y) + { + if (x is SongObject sx && y is SongObject sy) + return Compare(sx, sy); + + throw new InvalidOperationException(); + } + } +} \ No newline at end of file diff --git a/YARG.Core/MoonscraperChartParser/Events/BPM.cs b/YARG.Core/MoonscraperChartParser/Events/BPM.cs index 6ce1a9cb6..0fd1f08f4 100644 --- a/YARG.Core/MoonscraperChartParser/Events/BPM.cs +++ b/YARG.Core/MoonscraperChartParser/Events/BPM.cs @@ -31,5 +31,21 @@ public BPM(uint _position = 0, uint _value = 120000, double? _anchor = null) : b value = _value; anchor = _anchor; } + + protected override SyncTrack SyncClone() => Clone(); + + public new BPM Clone() + { + return new BPM(tick, value, anchor) + { + assignedTime = assignedTime, + song = song, + }; + } + + public override string ToString() + { + return $"BPM at tick {tick} with tempo {displayValue}"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/ChartEvent.cs b/YARG.Core/MoonscraperChartParser/Events/ChartEvent.cs index 6e884e19b..17f83b7cb 100644 --- a/YARG.Core/MoonscraperChartParser/Events/ChartEvent.cs +++ b/YARG.Core/MoonscraperChartParser/Events/ChartEvent.cs @@ -47,5 +47,21 @@ protected override bool LessThan(SongObject b) else return base.LessThan(b); } + + protected override ChartObject ChartClone() => Clone(); + + public new ChartEvent Clone() + { + return new ChartEvent(tick, eventName) + { + song = song, + chart = chart, + }; + } + + public override string ToString() + { + return $"Local event at tick {tick} with text '{eventName}'"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/ChartObject.cs b/YARG.Core/MoonscraperChartParser/Events/ChartObject.cs index 7c0d69cd1..b382e8211 100644 --- a/YARG.Core/MoonscraperChartParser/Events/ChartObject.cs +++ b/YARG.Core/MoonscraperChartParser/Events/ChartObject.cs @@ -12,5 +12,10 @@ public abstract class ChartObject : SongObject public MoonChart chart; public ChartObject(uint position) : base(position) { } + + // Clone needs to be hideable so it can return a different type in derived classes + protected override SongObject SongClone() => ChartClone(); + protected abstract ChartObject ChartClone(); + public new ChartObject Clone() => ChartClone(); } } diff --git a/YARG.Core/MoonscraperChartParser/Events/Event.cs b/YARG.Core/MoonscraperChartParser/Events/Event.cs index 4888a9014..0d709f452 100644 --- a/YARG.Core/MoonscraperChartParser/Events/Event.cs +++ b/YARG.Core/MoonscraperChartParser/Events/Event.cs @@ -47,5 +47,20 @@ protected override bool LessThan(SongObject b) else return base.LessThan(b); } + + protected override SongObject SongClone() => Clone(); + + public new Event Clone() + { + return new Event(title, tick) + { + song = song, + }; + } + + public override string ToString() + { + return $"Global event at tick {tick} with text '{title}'"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/MoonNote.cs b/YARG.Core/MoonscraperChartParser/Events/MoonNote.cs index c2992ed43..46de4d0c0 100644 --- a/YARG.Core/MoonscraperChartParser/Events/MoonNote.cs +++ b/YARG.Core/MoonscraperChartParser/Events/MoonNote.cs @@ -411,5 +411,21 @@ public static int MakeProGuitarRawNote(ProGuitarString proString, int fret) rawNote |= ((int)proString << PRO_GUITAR_STRING_OFFSET) & PRO_GUITAR_STRING_MASK; return rawNote; } + + protected override ChartObject ChartClone() => Clone(); + + public new MoonNote Clone() + { + return new MoonNote(tick, rawNote, length, flags) + { + song = song, + chart = chart, + }; + } + + public override string ToString() + { + return $"Note at tick {tick} with value {rawNote} and length {length}"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/Section.cs b/YARG.Core/MoonscraperChartParser/Events/Section.cs index e43329b9b..8827ef697 100644 --- a/YARG.Core/MoonscraperChartParser/Events/Section.cs +++ b/YARG.Core/MoonscraperChartParser/Events/Section.cs @@ -14,5 +14,20 @@ public class Section : Event public Section(string _title, uint _position) : base(_title, _position) { } public Section(Section section) : base(section.title, section.tick) { } + + protected override SongObject SongClone() => Clone(); + + public new Section Clone() + { + return new Section(title, tick) + { + song = song, + }; + } + + public override string ToString() + { + return $"Section at tick {tick} with name '{title}'"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/SongObject.cs b/YARG.Core/MoonscraperChartParser/Events/SongObject.cs index 3b93a3b43..1aeef4a11 100644 --- a/YARG.Core/MoonscraperChartParser/Events/SongObject.cs +++ b/YARG.Core/MoonscraperChartParser/Events/SongObject.cs @@ -30,6 +30,10 @@ public SongObject(uint _tick) /// public double time => song.TickToTime(tick, song.resolution); + // Clone needs to be hideable so it can return a different type in derived classes + protected abstract SongObject SongClone(); + public SongObject Clone() => SongClone(); + public static bool operator ==(SongObject a, SongObject b) { bool aIsNull = a is null; @@ -89,6 +93,11 @@ public override int GetHashCode() return base.GetHashCode(); } + public override string ToString() + { + return $"{classID} at tick {tick}"; + } + /// /// Allows different classes to be sorted and grouped together in arrays by giving each class a comparable numeric value that is greater or less than other classes. /// diff --git a/YARG.Core/MoonscraperChartParser/Events/SpecialPhrase.cs b/YARG.Core/MoonscraperChartParser/Events/SpecialPhrase.cs index 8410f0717..f5415d330 100644 --- a/YARG.Core/MoonscraperChartParser/Events/SpecialPhrase.cs +++ b/YARG.Core/MoonscraperChartParser/Events/SpecialPhrase.cs @@ -108,5 +108,21 @@ public uint GetCappedLengthForPos(uint pos) return newLength; } + + protected override ChartObject ChartClone() => Clone(); + + public new SpecialPhrase Clone() + { + return new SpecialPhrase(tick, length, type) + { + song = song, + chart = chart, + }; + } + + public override string ToString() + { + return $"Special phrase at tick {tick} with type {type} and length {length}"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/Events/SyncTrack.cs b/YARG.Core/MoonscraperChartParser/Events/SyncTrack.cs index 90fe30737..8e920b3f6 100644 --- a/YARG.Core/MoonscraperChartParser/Events/SyncTrack.cs +++ b/YARG.Core/MoonscraperChartParser/Events/SyncTrack.cs @@ -9,5 +9,10 @@ namespace MoonscraperChartEditor.Song public abstract class SyncTrack : SongObject { public SyncTrack(uint _position) : base(_position) { } + + // Clone needs to be hideable so it can return a different type in derived classes + protected override SongObject SongClone() => SyncClone(); + protected abstract SyncTrack SyncClone(); + public new SyncTrack Clone() => SyncClone(); } } diff --git a/YARG.Core/MoonscraperChartParser/Events/TimeSignature.cs b/YARG.Core/MoonscraperChartParser/Events/TimeSignature.cs index 841b8809b..c7f073172 100644 --- a/YARG.Core/MoonscraperChartParser/Events/TimeSignature.cs +++ b/YARG.Core/MoonscraperChartParser/Events/TimeSignature.cs @@ -63,5 +63,20 @@ public MeasureInfo GetMeasureInfo() return measureInfo; } + + protected override SyncTrack SyncClone() => Clone(); + + public new TimeSignature Clone() + { + return new TimeSignature(tick, numerator, denominator) + { + song = song, + }; + } + + public override string ToString() + { + return $"Time signature at tick {tick} with numerator {numerator} and denominator {denominator}"; + } } } diff --git a/YARG.Core/MoonscraperChartParser/IO/Midi/MidReader.cs b/YARG.Core/MoonscraperChartParser/IO/Midi/MidReader.cs index 99310a6b1..5e98af019 100644 --- a/YARG.Core/MoonscraperChartParser/IO/Midi/MidReader.cs +++ b/YARG.Core/MoonscraperChartParser/IO/Midi/MidReader.cs @@ -260,7 +260,7 @@ private static void ReadSync(TempoMap tempoMap, MoonSong song) } foreach (var timesig in tempoMap.GetTimeSignatureChanges()) { - song.Add(new TimeSignature((uint)timesig.Time, (uint)timesig.Value.Numerator, (uint)Math.Pow(2, timesig.Value.Denominator)), false); + song.Add(new TimeSignature((uint)timesig.Time, (uint)timesig.Value.Numerator, (uint)timesig.Value.Denominator), false); } song.UpdateCache(); @@ -657,7 +657,7 @@ private static void TransferHarm1StarPower(in EventProcessParams processParams) if (phrase.type == SpecialPhrase.Type.Starpower) { // Make a new copy instead of adding the original reference - chart.Add(new SpecialPhrase(phrase.tick, phrase.length, phrase.type), false); + chart.Add(phrase.Clone(), false); } } @@ -686,7 +686,7 @@ private static void TransferHarm2LyricPhrases(in EventProcessParams processParam if (phrase.type == SpecialPhrase.Type.Starpower) { // Make a new copy instead of adding the original reference - chart.Add(new SpecialPhrase(phrase.tick, phrase.length, phrase.type), false); + chart.Add(phrase.Clone(), false); } } @@ -855,6 +855,8 @@ private static Dictionary BuildProGuitarMidiNoteNumberToPro { MidIOHelper.SOLO_NOTE_PRO_GUITAR, (in EventProcessParams eventProcessParams) => { ProcessNoteOnEventAsEvent(eventProcessParams, MidIOHelper.SOLO_EVENT_TEXT, MidIOHelper.SOLO_END_EVENT_TEXT, tickEndOffset: SOLO_END_CORRECTION_OFFSET); }}, + { MidIOHelper.TREMOLO_LANE_NOTE, ProcessNoteOnEventAsTremoloLane }, + { MidIOHelper.TRILL_LANE_NOTE, ProcessNoteOnEventAsTrillLane }, }; foreach (var difficulty in EnumX.Values) diff --git a/YARG.Core/MoonscraperChartParser/MoonSong.cs b/YARG.Core/MoonscraperChartParser/MoonSong.cs index 34d45e3b8..560974acc 100644 --- a/YARG.Core/MoonscraperChartParser/MoonSong.cs +++ b/YARG.Core/MoonscraperChartParser/MoonSong.cs @@ -75,9 +75,7 @@ public MoonSong() Add(new TimeSignature()); // Chart initialisation - int numberOfInstruments = EnumX.Count - 1; // Don't count the "Unused" instrument - charts = new MoonChart[numberOfInstruments * EnumX.Count]; - + charts = new MoonChart[EnumX.Count * EnumX.Count]; for (int i = 0; i < charts.Length; ++i) { var instrument = (MoonInstrument)(i / EnumX.Count); @@ -114,15 +112,7 @@ public MoonSong(MoonSong song) : this() public MoonChart GetChart(MoonInstrument instrument, Difficulty difficulty) { - try - { - return charts[(int)instrument * EnumX.Count + (int)difficulty]; - } - catch (Exception e) - { - Debug.WriteLine(e.Message); - return charts[0]; - } + return charts[(int)instrument * EnumX.Count + (int)difficulty]; } public bool ChartExistsForInstrument(MoonInstrument instrument)