diff --git a/RhythmGameUtilities.Tests/MidiTest.cs b/RhythmGameUtilities.Tests/MidiTest.cs new file mode 100644 index 0000000..522e041 --- /dev/null +++ b/RhythmGameUtilities.Tests/MidiTest.cs @@ -0,0 +1,96 @@ +using System.IO; +using NUnit.Framework; + +namespace RhythmGameUtilities.Tests +{ + + public class MidiTest + { + + [Test] + public void TestReadMidiData() + { + var directory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + var path = Path.GetFullPath(Path.Combine(directory, "../../../Mocks/song.mid")); + + var content = File.ReadAllBytes(path); + + var notes = Midi.ReadMidiData(content); + + Assert.That(notes.Length, Is.EqualTo(10)); + + Assert.That(notes[0].Position, Is.EqualTo(0)); + Assert.That(notes[0].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[1].Position, Is.EqualTo(480)); + Assert.That(notes[1].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[2].Position, Is.EqualTo(960)); + Assert.That(notes[2].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[3].Position, Is.EqualTo(1440)); + Assert.That(notes[3].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[4].Position, Is.EqualTo(1920)); + Assert.That(notes[4].HandPosition, Is.EqualTo(56)); + + Assert.That(notes[5].Position, Is.EqualTo(2400)); + Assert.That(notes[5].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[6].Position, Is.EqualTo(2400)); + Assert.That(notes[6].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[7].Position, Is.EqualTo(2400)); + Assert.That(notes[7].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[8].Position, Is.EqualTo(2400)); + Assert.That(notes[8].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[9].Position, Is.EqualTo(2400)); + Assert.That(notes[9].HandPosition, Is.EqualTo(56)); + } + + [Test] + public void TestReadMidiFile() + { + var directory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + var path = Path.GetFullPath(Path.Combine(directory, "../../../Mocks/song.mid")); + + var notes = Midi.ReadMidiFile(path); + + Assert.That(notes.Length, Is.EqualTo(10)); + + Assert.That(notes[0].Position, Is.EqualTo(0)); + Assert.That(notes[0].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[1].Position, Is.EqualTo(480)); + Assert.That(notes[1].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[2].Position, Is.EqualTo(960)); + Assert.That(notes[2].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[3].Position, Is.EqualTo(1440)); + Assert.That(notes[3].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[4].Position, Is.EqualTo(1920)); + Assert.That(notes[4].HandPosition, Is.EqualTo(56)); + + Assert.That(notes[5].Position, Is.EqualTo(2400)); + Assert.That(notes[5].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[6].Position, Is.EqualTo(2400)); + Assert.That(notes[6].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[7].Position, Is.EqualTo(2400)); + Assert.That(notes[7].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[8].Position, Is.EqualTo(2400)); + Assert.That(notes[8].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[9].Position, Is.EqualTo(2400)); + Assert.That(notes[9].HandPosition, Is.EqualTo(56)); + } + + } + +} diff --git a/RhythmGameUtilities.Tests/Mocks/song.mid b/RhythmGameUtilities.Tests/Mocks/song.mid new file mode 100644 index 0000000..383958a Binary files /dev/null and b/RhythmGameUtilities.Tests/Mocks/song.mid differ diff --git a/RhythmGameUtilities/Scripts/Midi.cs b/RhythmGameUtilities/Scripts/Midi.cs new file mode 100644 index 0000000..e4641d7 --- /dev/null +++ b/RhythmGameUtilities/Scripts/Midi.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace RhythmGameUtilities +{ + + internal static class MidiInternal + { + + [DllImport("libRhythmGameUtilities", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ReadMidiDataInternal(byte[] bytes, int dataSize, out int size); + + [DllImport("libRhythmGameUtilities", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ReadMidiFileInternal(string filename, out int size); + + } + + public static class Midi + { + + public static Note[] ReadMidiData(byte[] bytes) + { + var notes = new List(); + + var ptrArray = MidiInternal.ReadMidiDataInternal(bytes, bytes.Length, out var size); + + var noteSize = Marshal.SizeOf(typeof(Note)); + + for (var i = 0; i < size; i += 1) + { + var noteSizePtr = new IntPtr(ptrArray.ToInt64() + noteSize * i); + var note = Marshal.PtrToStructure(noteSizePtr); + + notes.Add(note); + } + + Marshal.FreeHGlobal(ptrArray); + + return notes.ToArray(); + } + + public static Note[] ReadMidiFile(string path) + { + var notes = new List(); + + var ptrArray = MidiInternal.ReadMidiFileInternal(path, out var size); + + var noteSize = Marshal.SizeOf(typeof(Note)); + + for (var i = 0; i < size; i += 1) + { + var noteSizePtr = new IntPtr(ptrArray.ToInt64() + noteSize * i); + var note = Marshal.PtrToStructure(noteSizePtr); + + notes.Add(note); + } + + Marshal.FreeHGlobal(ptrArray); + + return notes.ToArray(); + } + + } + +} diff --git a/UnityPackage/Editor/Tests/MidiTest.cs b/UnityPackage/Editor/Tests/MidiTest.cs new file mode 100644 index 0000000..522e041 --- /dev/null +++ b/UnityPackage/Editor/Tests/MidiTest.cs @@ -0,0 +1,96 @@ +using System.IO; +using NUnit.Framework; + +namespace RhythmGameUtilities.Tests +{ + + public class MidiTest + { + + [Test] + public void TestReadMidiData() + { + var directory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + var path = Path.GetFullPath(Path.Combine(directory, "../../../Mocks/song.mid")); + + var content = File.ReadAllBytes(path); + + var notes = Midi.ReadMidiData(content); + + Assert.That(notes.Length, Is.EqualTo(10)); + + Assert.That(notes[0].Position, Is.EqualTo(0)); + Assert.That(notes[0].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[1].Position, Is.EqualTo(480)); + Assert.That(notes[1].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[2].Position, Is.EqualTo(960)); + Assert.That(notes[2].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[3].Position, Is.EqualTo(1440)); + Assert.That(notes[3].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[4].Position, Is.EqualTo(1920)); + Assert.That(notes[4].HandPosition, Is.EqualTo(56)); + + Assert.That(notes[5].Position, Is.EqualTo(2400)); + Assert.That(notes[5].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[6].Position, Is.EqualTo(2400)); + Assert.That(notes[6].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[7].Position, Is.EqualTo(2400)); + Assert.That(notes[7].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[8].Position, Is.EqualTo(2400)); + Assert.That(notes[8].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[9].Position, Is.EqualTo(2400)); + Assert.That(notes[9].HandPosition, Is.EqualTo(56)); + } + + [Test] + public void TestReadMidiFile() + { + var directory = Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location); + var path = Path.GetFullPath(Path.Combine(directory, "../../../Mocks/song.mid")); + + var notes = Midi.ReadMidiFile(path); + + Assert.That(notes.Length, Is.EqualTo(10)); + + Assert.That(notes[0].Position, Is.EqualTo(0)); + Assert.That(notes[0].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[1].Position, Is.EqualTo(480)); + Assert.That(notes[1].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[2].Position, Is.EqualTo(960)); + Assert.That(notes[2].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[3].Position, Is.EqualTo(1440)); + Assert.That(notes[3].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[4].Position, Is.EqualTo(1920)); + Assert.That(notes[4].HandPosition, Is.EqualTo(56)); + + Assert.That(notes[5].Position, Is.EqualTo(2400)); + Assert.That(notes[5].HandPosition, Is.EqualTo(48)); + + Assert.That(notes[6].Position, Is.EqualTo(2400)); + Assert.That(notes[6].HandPosition, Is.EqualTo(50)); + + Assert.That(notes[7].Position, Is.EqualTo(2400)); + Assert.That(notes[7].HandPosition, Is.EqualTo(52)); + + Assert.That(notes[8].Position, Is.EqualTo(2400)); + Assert.That(notes[8].HandPosition, Is.EqualTo(54)); + + Assert.That(notes[9].Position, Is.EqualTo(2400)); + Assert.That(notes[9].HandPosition, Is.EqualTo(56)); + } + + } + +} diff --git a/UnityPackage/Editor/Tests/MidiTest.cs.meta b/UnityPackage/Editor/Tests/MidiTest.cs.meta new file mode 100644 index 0000000..271fe58 --- /dev/null +++ b/UnityPackage/Editor/Tests/MidiTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 192d05baf03de47b484e890166b20c1b \ No newline at end of file diff --git a/UnityPackage/Scripts/Midi.cs b/UnityPackage/Scripts/Midi.cs new file mode 100644 index 0000000..e4641d7 --- /dev/null +++ b/UnityPackage/Scripts/Midi.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; + +namespace RhythmGameUtilities +{ + + internal static class MidiInternal + { + + [DllImport("libRhythmGameUtilities", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ReadMidiDataInternal(byte[] bytes, int dataSize, out int size); + + [DllImport("libRhythmGameUtilities", CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr ReadMidiFileInternal(string filename, out int size); + + } + + public static class Midi + { + + public static Note[] ReadMidiData(byte[] bytes) + { + var notes = new List(); + + var ptrArray = MidiInternal.ReadMidiDataInternal(bytes, bytes.Length, out var size); + + var noteSize = Marshal.SizeOf(typeof(Note)); + + for (var i = 0; i < size; i += 1) + { + var noteSizePtr = new IntPtr(ptrArray.ToInt64() + noteSize * i); + var note = Marshal.PtrToStructure(noteSizePtr); + + notes.Add(note); + } + + Marshal.FreeHGlobal(ptrArray); + + return notes.ToArray(); + } + + public static Note[] ReadMidiFile(string path) + { + var notes = new List(); + + var ptrArray = MidiInternal.ReadMidiFileInternal(path, out var size); + + var noteSize = Marshal.SizeOf(typeof(Note)); + + for (var i = 0; i < size; i += 1) + { + var noteSizePtr = new IntPtr(ptrArray.ToInt64() + noteSize * i); + var note = Marshal.PtrToStructure(noteSizePtr); + + notes.Add(note); + } + + Marshal.FreeHGlobal(ptrArray); + + return notes.ToArray(); + } + + } + +} diff --git a/UnityPackage/Scripts/Midi.cs.meta b/UnityPackage/Scripts/Midi.cs.meta new file mode 100644 index 0000000..092e6c7 --- /dev/null +++ b/UnityPackage/Scripts/Midi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4532fba60d1fc4e578d72a714cfd9ac1 \ No newline at end of file diff --git a/include/RhythmGameUtilities/Midi.hpp b/include/RhythmGameUtilities/Midi.hpp new file mode 100644 index 0000000..0c90034 --- /dev/null +++ b/include/RhythmGameUtilities/Midi.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "Structs/Note.hpp" + +namespace RhythmGameUtilities +{ + +template inline auto ByteSwap(T value, int length = 8) -> T +{ + return (value >> length) | (value << length); +} + +template +inline auto ReadChunk(std::istringstream &stream, int length = sizeof(T)) -> T +{ + T chunk{}; + stream.read(reinterpret_cast(&chunk), length); + return chunk; +} + +inline auto ReadString(std::istringstream &stream, int length) -> std::string +{ + std::string chunk(length, '\0'); + stream.read(chunk.data(), length); + return stream.gcount() == length ? chunk : ""; +} + +inline auto ReadVarLen(std::istringstream &stream) -> uint32_t +{ + uint32_t value = 0; + uint8_t byte = 0; + do + { + byte = ReadChunk(stream); + value = (value << 7) | (byte & 0x7F); + } while ((byte & 0x80) > 0); + return value; +} + +const auto TYPE_END_OF_TRACK = 0x2F; + +const auto SYSTEM_COMMAND = 0xF0; +const auto NOTE_OFF_COMMAND = 0x80; +const auto NOTE_ON_COMMAND = 0x90; +const auto CONTROL_CHANGE_COMMAND = 0xB0; +const auto PROGRAM_CHANGE_COMMAND = 0xC0; +const auto CHANNEL_PRESSURE_COMMAND = 0xD0; + +inline auto ReadMidiData(const std::vector &data) -> std::vector +{ + std::istringstream stream(std::string(data.begin(), data.end()), + std::ios::binary); + + if (ReadString(stream, 4) != "MThd") + { + throw std::runtime_error("Invalid MIDI file header"); + } + + auto headerLength = ByteSwap(ReadChunk(stream)); + auto format = ByteSwap(ReadChunk(stream)); + auto tracks = ByteSwap(ReadChunk(stream)); + auto resolution = ByteSwap(ReadChunk(stream)); + + std::vector notes; + + for (int t = 0; t < tracks; t += 1) + { + if (ReadString(stream, 4) != "MTrk") + { + throw std::runtime_error("Invalid track header"); + } + + auto trackLength = ByteSwap(ReadChunk(stream)); + + uint32_t absoluteTick = 0; + + while (true) + { + absoluteTick += ReadVarLen(stream); + + auto status = ReadChunk(stream); + + if ((status & SYSTEM_COMMAND) == NOTE_ON_COMMAND) + { + Note note{.Position = static_cast(absoluteTick), + .HandPosition = ReadChunk(stream)}; + + auto velocity = ReadChunk(stream); + + notes.push_back(note); + } + else if ((status & SYSTEM_COMMAND) == SYSTEM_COMMAND) + { + auto type = ReadChunk(stream); + + auto length = ReadVarLen(stream); + + stream.seekg(length, std::ios::cur); + + if (type == TYPE_END_OF_TRACK) + { + break; + } + } + else if ((status & SYSTEM_COMMAND) == PROGRAM_CHANGE_COMMAND || + (status & SYSTEM_COMMAND) == CHANNEL_PRESSURE_COMMAND) + { + stream.seekg(1, std::ios::cur); + } + else + { + stream.seekg(2, std::ios::cur); + } + } + } + + return notes; +} + +auto ReadMidiFile(const std::string &path) -> std::vector +{ + std::ifstream file(path, std::ios::binary | std::ios::ate); + + if (!file.is_open()) + { + throw std::runtime_error("Cannot open MIDI file"); + } + + auto fileSize = file.tellg(); + + std::vector buffer(static_cast(fileSize)); + + file.seekg(0, std::ios::beg); + + file.read(reinterpret_cast(buffer.data()), fileSize); + + return ReadMidiData(buffer); +} + +} // namespace RhythmGameUtilities diff --git a/include/RhythmGameUtilities/MidiInternal.hpp b/include/RhythmGameUtilities/MidiInternal.hpp new file mode 100644 index 0000000..1b44524 --- /dev/null +++ b/include/RhythmGameUtilities/MidiInternal.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include "Structs/Note.hpp" + +#include "Midi.hpp" + +#ifdef _WIN32 +#define PACKAGE_API __declspec(dllexport) +#else +#define PACKAGE_API +#endif + +namespace RhythmGameUtilities +{ + +extern "C" +{ + + PACKAGE_API auto ReadMidiDataInternal(const uint8_t *data, int dataSize, + int *outSize) -> Note * + { + std::vector byteVector(data, data + dataSize); + + auto internalNotes = ReadMidiData(byteVector); + + *outSize = internalNotes.size(); + + auto *notes = (Note *)malloc(internalNotes.size() * sizeof(Note)); + + for (auto i = 0; i < internalNotes.size(); i += 1) + { + notes[i] = internalNotes[i]; + } + + return notes; + } + + PACKAGE_API auto ReadMidiFileInternal(const std::string &path, int *outSize) + -> Note * + { + auto internalNotes = ReadMidiFile(path); + + *outSize = internalNotes.size(); + + auto *notes = (Note *)malloc(internalNotes.size() * sizeof(Note)); + + for (auto i = 0; i < internalNotes.size(); i += 1) + { + notes[i] = internalNotes[i]; + } + + return notes; + } +} + +} // namespace RhythmGameUtilities diff --git a/include/RhythmGameUtilities/RhythmGameUtilities.cpp b/include/RhythmGameUtilities/RhythmGameUtilities.cpp index ef3c4bd..9d1d6af 100644 --- a/include/RhythmGameUtilities/RhythmGameUtilities.cpp +++ b/include/RhythmGameUtilities/RhythmGameUtilities.cpp @@ -2,6 +2,8 @@ #include "Audio.hpp" #include "Common.hpp" +#include "Midi.hpp" +#include "MidiInternal.hpp" #include "Parsers.hpp" #include "ParsersInternal.hpp" #include "Utilities.hpp" diff --git a/tests/Mocks/song.mid b/tests/Mocks/song.mid new file mode 100644 index 0000000..383958a Binary files /dev/null and b/tests/Mocks/song.mid differ diff --git a/tests/RhythmGameUtilities/Midi.cpp b/tests/RhythmGameUtilities/Midi.cpp new file mode 100644 index 0000000..d314d02 --- /dev/null +++ b/tests/RhythmGameUtilities/Midi.cpp @@ -0,0 +1,95 @@ +#include +#include + +#include "RhythmGameUtilities/File.hpp" +#include "RhythmGameUtilities/Midi.hpp" + +using namespace RhythmGameUtilities; + +void testReadMidiData() +{ + auto bytes = ReadBytesFromFile("./tests/Mocks/song.mid"); + + auto notes = ReadMidiData(bytes); + + assert(size(notes) == 10); + + assert(notes[0].Position == 0); + assert(notes[0].HandPosition == 48); + + assert(notes[1].Position == 480); + assert(notes[1].HandPosition == 50); + + assert(notes[2].Position == 960); + assert(notes[2].HandPosition == 52); + + assert(notes[3].Position == 1440); + assert(notes[3].HandPosition == 54); + + assert(notes[4].Position == 1920); + assert(notes[4].HandPosition == 56); + + assert(notes[5].Position == 2400); + assert(notes[5].HandPosition == 48); + + assert(notes[6].Position == 2400); + assert(notes[6].HandPosition == 50); + + assert(notes[7].Position == 2400); + assert(notes[7].HandPosition == 52); + + assert(notes[8].Position == 2400); + assert(notes[8].HandPosition == 54); + + assert(notes[9].Position == 2400); + assert(notes[9].HandPosition == 56); + + std::cout << "."; +} + +void testReadMidiFile() +{ + auto notes = ReadMidiFile("./tests/Mocks/song.mid"); + + assert(size(notes) == 10); + + assert(notes[0].Position == 0); + assert(notes[0].HandPosition == 48); + + assert(notes[1].Position == 480); + assert(notes[1].HandPosition == 50); + + assert(notes[2].Position == 960); + assert(notes[2].HandPosition == 52); + + assert(notes[3].Position == 1440); + assert(notes[3].HandPosition == 54); + + assert(notes[4].Position == 1920); + assert(notes[4].HandPosition == 56); + + assert(notes[5].Position == 2400); + assert(notes[5].HandPosition == 48); + + assert(notes[6].Position == 2400); + assert(notes[6].HandPosition == 50); + + assert(notes[7].Position == 2400); + assert(notes[7].HandPosition == 52); + + assert(notes[8].Position == 2400); + assert(notes[8].HandPosition == 54); + + assert(notes[9].Position == 2400); + assert(notes[9].HandPosition == 56); + + std::cout << "."; +} + +int main() +{ + testReadMidiData(); + testReadMidiFile(); + + return 0; +}