399 lines
16 KiB
C++
399 lines
16 KiB
C++
#include "Track.h"
|
|
|
|
#include <choc/audio/choc_MIDI.h>
|
|
#include <choc/audio/choc_MIDIFile.h>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <map>
|
|
#include <ranges>
|
|
#include <span>
|
|
|
|
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<uint8_t> buffer(fileSize);
|
|
if (!file.read(reinterpret_cast<char *>(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<uint8_t> 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<uint32_t>(msg.data()[3] << 16) |
|
|
static_cast<uint32_t>(msg.data()[4] << 8) |
|
|
static_cast<uint32_t>(msg.data()[5]);
|
|
MidiTempoEvents tempoEvent{ .tick = event.tickPosition,
|
|
.microsecondsPerQuarterNote = static_cast<double>(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<double>(midiFile.timeFormat);
|
|
}
|
|
else if (midiFile.timeFormat < 0)
|
|
{
|
|
// Manage SMPTE time format (negative value) to calculate ticks per beat
|
|
const int framesPerSecond = -static_cast<int>(midiFile.timeFormat >> 8);
|
|
const int ticksPerFrame = static_cast<int>(midiFile.timeFormat & 0xFF);
|
|
ticksPerBeat = static_cast<double>(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<double>(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<int64_t>((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<double>(tick - tempoEvent.tick);
|
|
|
|
// Convert tick delta to milliseconds
|
|
const auto timeDeltaMs = static_cast<int64_t>((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<int, MidiNoteEvent> activeNotesMap;
|
|
map<std::pair<int, int>, 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<int>(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<size_t>(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<NoteEvent> 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) };
|
|
}
|
|
//--------------------------------------------------------------
|