Files
volaTile/src/core/Track/Track.cpp
Sylvain Schneider 888765ef6b code integration
2026-07-04 21:17:38 +02:00

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 &noteEvent : 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 &noteOnEvent = 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 &noteOnEvent : 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 &noteEvent : 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) };
}
//--------------------------------------------------------------