#include "Track.h" #include #include #include #include #include #include #include using namespace std; //-------------------------------------------------------------- /* Load a MIDI file from disk */ void Track::loadFromFile(const std::filesystem::path &filePath) { cout << "Loading MIDI file: " << filePath.string() << endl; // Open the MIDI file in binary mode and place the file pointer at the end to determine its size std::ifstream file(filePath, std::ifstream::binary | std::ifstream::ate); if (!file.is_open()) throw runtime_error("Failed to open MIDI file: " + filePath.string()); const auto fileSize = file.tellg(); file.seekg(0, std::ios::beg); // Read the entire file into a buffer std::vector buffer(fileSize); if (!file.read(reinterpret_cast(buffer.data()), fileSize)) throw runtime_error("Failed to read MIDI file: " + filePath.string()); // Load the MIDI data from memory return loadFromMemory(buffer); } //-------------------------------------------------------------- /* Load a MIDI file from memory */ void Track::loadFromMemory(std::span midiBytes) { cout << "Loading MIDI data from memory, size: " << midiBytes.size() << " bytes" << endl; scoped_lock lock(m_mtx); // Lock the mutex to protect access to the data structures // Clear any existing data before loading new MIDI data m_noteEvents.clear(); m_tempoEvents.clear(); m_notesByStart.clear(); m_notesByEnd.clear(); // Load midi sequence from memory using choc::midi::File choc::midi::File midiFile; midiFile.load(midiBytes.data(), midiBytes.size()); // --- Step 1: Extract note events and tempo changes from the parsed MIDI data --- // Default tempo in microseconds per quarter note (500,000 us = 120 BPM) constexpr double defaultTempoPerQuarterNote = 500000.0; int trackID = 0; for (const auto &track : midiFile.tracks) { for (const auto &event : track.events) { const auto &msg = event.message; // Security check to ensure the message has enough bytes for processing if (msg.length() == 0) [[unlikely]] continue; // Check for Meta events (e.g., tempo changes, time signature changes, etc.) if (msg.isMetaEvent()) { // Process tempo change events (Magic number 0x51 indicates a tempo change event) if (msg.length() >= 6 && msg.getMetaEventType() == 0x51) { const auto msPerQuarterNote = static_cast(msg.data()[3] << 16) | static_cast(msg.data()[4] << 8) | static_cast(msg.data()[5]); MidiTempoEvents tempoEvent{ .tick = event.tickPosition, .microsecondsPerQuarterNote = static_cast(msPerQuarterNote), .timeMs = Timestamp(0) }; // Time in milliseconds will be calculated later m_tempoEvents.push_back(tempoEvent); } } // Check for Note On events // Due to a bug on the choc::midi::Message class, we need to check the // length of the message before checking for Note On and Note Off events else if (msg.length() >= 3 && msg.isNoteOn()) { MidiNoteEvent noteEvent{ .tick = event.tickPosition, .pitch = msg.getNoteNumber(), .velocity = msg.getVelocity(), .track = trackID, .channel = msg.getChannel0to15(), .noteOn = true }; m_noteEvents.push_back(noteEvent); } // Check for Note Off events // Due to a bug on the choc::midi::Message class, we need to check the // length of the message before checking for Note On and Note Off events else if (msg.length() >= 3 && msg.isNoteOff()) { MidiNoteEvent noteEvent{ .tick = event.tickPosition, .pitch = msg.getNoteNumber(), .velocity = msg.getVelocity(), .track = trackID, .channel = msg.getChannel0to15(), .noteOn = false }; m_noteEvents.push_back(noteEvent); } } ++trackID; } // --- Step 2: Convert ticks to timestamps in milliseconds --- // Extract the time format from the MIDI file to determine ticks per beat double ticksPerBeat = 480.0; if (midiFile.timeFormat > 0) { ticksPerBeat = static_cast(midiFile.timeFormat); } else if (midiFile.timeFormat < 0) { // Manage SMPTE time format (negative value) to calculate ticks per beat const int framesPerSecond = -static_cast(midiFile.timeFormat >> 8); const int ticksPerFrame = static_cast(midiFile.timeFormat & 0xFF); ticksPerBeat = static_cast(framesPerSecond * ticksPerFrame); } // If ticksPerBeat is zero or negative, set it to a default value of 480.0 if (ticksPerBeat <= 0.0) [[unlikely]] { ticksPerBeat = 480.0; } // Calculate the time in milliseconds for each tempo change event if (m_tempoEvents.empty()) { // If no tempo events were found, use the default tempo for the entire track m_tempoEvents.push_back({ .tick = 0, .microsecondsPerQuarterNote = defaultTempoPerQuarterNote, .timeMs = Timestamp(0) }); } else { // Sort the tempo events by tick to ensure they are in chronological order std::ranges::sort(m_tempoEvents, std::less<>{}, &MidiTempoEvents::tick); // Security check: if the first tempo event is not at tick 0, insert a default tempo event at tick 0 if (m_tempoEvents.front().tick > 0) { constexpr MidiTempoEvents startTempoEvent{ .tick = 0, .microsecondsPerQuarterNote = defaultTempoPerQuarterNote, .timeMs = Timestamp(0) }; // Time in milliseconds will be calculated later m_tempoEvents.insert(m_tempoEvents.begin(), startTempoEvent); } auto currentTimestamp = Timestamp(0); int64_t currentTick = 0; auto currentTempo = m_tempoEvents[0].microsecondsPerQuarterNote; m_tempoEvents[0].timeMs = Timestamp(0); // The first tempo event starts at time 0 for (size_t i = 1; i < m_tempoEvents.size(); i++) { const double tickDelta = static_cast(m_tempoEvents[i].tick - currentTick); // Calculate the time delta in milliseconds based on the current tempo // Time in milliseconds = (tickDelta * microsecondsPerQuarterNote) / (ticksPerBeat * 1000) const auto timeDeltaMs = static_cast((tickDelta * currentTempo) / (ticksPerBeat * 1000.0)); // Update the current timestamp and store it in the tempo event currentTimestamp += Timestamp(timeDeltaMs); currentTick = m_tempoEvents[i].tick; currentTempo = m_tempoEvents[i].microsecondsPerQuarterNote; // Store the calculated time in milliseconds for the tempo event m_tempoEvents[i].timeMs = currentTimestamp; } } // --- Step 3: Convert note events from ticks to timestamps in milliseconds --- // Helper function to convert a tick value to a timestamp in milliseconds based on the tempo events const auto tickToMs = [this, ticksPerBeat](const int64_t tick) -> Timestamp { // Find the last tempo event that occurs before or at the given tick size_t tempoIndex = 0; for (size_t i = 0; i < m_tempoEvents.size(); i++) { if (m_tempoEvents[i].tick <= tick) tempoIndex = i; else break; } const auto &tempoEvent = m_tempoEvents[tempoIndex]; const double tickDelta = static_cast(tick - tempoEvent.tick); // Convert tick delta to milliseconds const auto timeDeltaMs = static_cast((tickDelta * tempoEvent.microsecondsPerQuarterNote) / (ticksPerBeat * 1000.0)); return tempoEvent.timeMs + Timestamp(timeDeltaMs); }; // --- Step 4: Create NoteEvent objects with start and end timestamps --- // Sort the note events by tick to ensure they are in chronological order std::ranges::sort(m_noteEvents, std::less<>{}, &MidiNoteEvent::tick); // Create a map to keep track of active notes (notes that have been started but not yet ended) // unordered_map activeNotesMap; map, MidiNoteEvent> activeNotesMap; // Estimate the number of notes to reserve space in the vectors for performance // Half of the note events are expected to be Note On events, so we reserve half the size m_notesByStart.reserve(m_noteEvents.size() / 2); for (auto ¬eEvent : m_noteEvents) { if (noteEvent.noteOn) { activeNotesMap[{ noteEvent.pitch, noteEvent.channel }] = noteEvent; // Store the Note On event in the active notes map } else { // Note Off event: extract the corresponding Note On event from the active notes map auto node = activeNotesMap.extract({ noteEvent.pitch, noteEvent.channel }); if (!node.empty()) { const auto ¬eOnEvent = node.mapped(); // Create a NoteEvent with start and end timestamps NoteEvent note; note.startTimestamp = tickToMs(noteOnEvent.tick); note.endTimestamp = tickToMs(noteEvent.tick); note.trackType = TrackType::Unknown; // Can be determined based on channel or other criteria note.pitch = noteOnEvent.pitch; note.velocity = noteOnEvent.velocity; note.noteOn = true; // Manage channel-specific logic to determine track type (LeftHand or RightHand) based on the track number if (noteOnEvent.track == 0) note.trackType = TrackType::RightHand; else if (noteOnEvent.track == 1) note.trackType = TrackType::LeftHand; // Add the NoteEvent to the vector and remove it from the active notes map m_notesByStart.push_back(note); } } } // Handle any remaining active notes (notes without a corresponding note off) // These will be extended to a reasonable default duration (e.g., 100ms) for (const auto ¬eOnEvent : activeNotesMap | views::values) { // Create a NoteEvent with a default end time (e.g., 100ms after the start time) NoteEvent note; note.startTimestamp = tickToMs(noteOnEvent.tick); note.endTimestamp = tickToMs(noteOnEvent.tick) + 100ms; note.trackType = TrackType::Unknown; note.pitch = noteOnEvent.pitch; note.velocity = noteOnEvent.velocity; note.noteOn = true; // Add the NoteEvent to the vector m_notesByStart.push_back(note); } // --- Step 5: Sort notes for efficient retrieval --- // Sort by start time ranges::sort(m_notesByStart, std::less<>{}, &NoteEvent::startTimestamp); // Create a sorted-by-end-time vector m_notesByEnd = m_notesByStart; ranges::sort(m_notesByEnd, std::less<>{}, &NoteEvent::endTimestamp); cout << "Loaded " << m_notesByStart.size() << " notes and " << m_tempoEvents.size() << " tempo changes." << endl; } //-------------------------------------------------------------- /* Clear the track data */ void Track::clear() { scoped_lock lock(m_mtx); // Lock the mutex to protect access to the data structures m_noteEvents.clear(); m_tempoEvents.clear(); m_notesByStart.clear(); m_notesByEnd.clear(); } //-------------------------------------------------------------- /* Check if a MIDI file is loaded */ bool Track::isLoaded() const { scoped_lock lock(m_mtx); // Lock the mutex to protect access to the data structures return !m_noteEvents.empty(); } //-------------------------------------------------------------- /* Get the duration of the track in milliseconds */ ITrack::Timestamp Track::getDuration() const { scoped_lock lock(m_mtx); // Lock the mutex to protect access to the data structures if (m_notesByEnd.empty()) return Timestamp(0); const auto &lastNote = m_notesByEnd.back(); return lastNote.endTimestamp; // Return the end timestamp of the last note } //-------------------------------------------------------------- /* Debug function to print the track data */ void Track::debug() { const auto timeToStr = [](const Timestamp &t) -> std::string { auto ms = t.count(); auto s = ms / 1000; auto m = s / 60; auto h = m / 60; ms = ms % 1000; s = s % 60; m = m % 60; return std::format("{:02}:{:02}:{:02}.{:03}", h, m, s, ms); }; for (const auto ¬eEvent : m_notesByStart) { cout << format("Type: {}, pitch={}, velocity={}, start={}, end={}\n", static_cast(noteEvent.trackType), noteEvent.pitch, noteEvent.velocity, timeToStr(noteEvent.startTimestamp), timeToStr(noteEvent.endTimestamp)); } cout << endl; } //-------------------------------------------------------------- /* Get notes in a given time window */ ITrack::TrackWindow Track::getTrackWindow(const Timestamp startTime, const Timestamp endTime) const { std::unique_lock lock(m_mtx); // Check if notes are loaded if (m_noteEvents.empty()) return { .activeNotes = {}, .upcomingNotes = {}, .lock = {} }; // --- Preparation of the upcoming notes --- // Find the first note that starts after or at the startTime const auto upStart = std::ranges::lower_bound(m_notesByStart, startTime, std::less<>{}, &NoteEvent::startTimestamp); // Find the first note that starts after or at the endTime const auto upEnd = std::ranges::lower_bound(upStart, m_notesByStart.end(), endTime, std::less<>{}, &NoteEvent::startTimestamp); // Create a span for the upcoming notes const auto count = static_cast(std::distance(upStart, upEnd)); if (count == 0) return { .activeNotes = {}, .upcomingNotes = {}, .lock = {} }; const std::span upcomingNotes(m_notesByStart.data() + std::distance(m_notesByStart.begin(), upStart), count); // --- Preparation of the active notes --- // Find the first note that ends after or at the startTime const auto activeStart = std::ranges::lower_bound(m_notesByEnd, startTime, std::less<>{}, &NoteEvent::endTimestamp); // Filter the active notes to include only those that have started before or at the endTime std::vector activeNotes; activeNotes.reserve(32); // Reserve space for 32 notes, which is often sufficient for a frame for (auto it = activeStart; it != m_notesByEnd.end(); ++it) { // Check if the note is active at the startTime // (i.e., it has started before or at startTime and has not // ended yet) if (it->startTimestamp <= startTime && it->endTimestamp > startTime) activeNotes.push_back(*it); // Stop if the note starts after the endTime, as we only // want active notes in the window if (it->startTimestamp > endTime) break; } // --- Create and return the track window with the active and upcoming notes --- return { .activeNotes = std::move(activeNotes), .upcomingNotes = upcomingNotes, .lock = std::move(lock) }; } //--------------------------------------------------------------